Merge branch 'master' of https://code.mnemo-cards.online/cinnabarflower/cards
This commit is contained in:
commit
167abca134
26 changed files with 6839 additions and 598 deletions
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"component": "backend",
|
"component": "backend",
|
||||||
"current_task_id": null,
|
"current_task_id": "BACKEND-009",
|
||||||
"iteration_count": 0,
|
"iteration_count": 7,
|
||||||
"max_iterations": 10,
|
"max_iterations": 10,
|
||||||
"started_at": null,
|
"started_at": "2025-11-21T01:06:15.789515+00:00",
|
||||||
"last_commit": null,
|
"last_commit": null,
|
||||||
"retry_count": 0,
|
"retry_count": 0,
|
||||||
"max_retries": 3,
|
"max_retries": 3,
|
||||||
"status": "idle",
|
"status": "in_progress",
|
||||||
"errors": [],
|
"errors": [],
|
||||||
"completed_tasks": [],
|
"completed_tasks": [],
|
||||||
"skipped_tasks": []
|
"skipped_tasks": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,241 @@
|
||||||
"project": "mnemo_cards_backend",
|
"project": "mnemo_cards_backend",
|
||||||
"component": "backend",
|
"component": "backend",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
"generated_at": null,
|
"generated_at": "2025-11-21T01:00:13.845013+00:00",
|
||||||
"generated_by": "planning_agent",
|
"generated_by": "planning_agent",
|
||||||
"tasks": []
|
"tasks": [
|
||||||
|
{
|
||||||
|
"id": "BACKEND-001",
|
||||||
|
"title": "Implement Subscriptions Plans Endpoint",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Implement GET /api/v2/subscriptions/plans endpoint to return available subscription plans. Use SubscriptionManager.getAllSubscriptionPlans() or getSubscriptionDto() to fetch plans and return them in proper format.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"GET /api/v2/subscriptions/plans returns 200 with list of plans",
|
||||||
|
"Plans are properly formatted as JSON",
|
||||||
|
"Endpoint handles authentication correctly",
|
||||||
|
"Returns empty array if no plans available",
|
||||||
|
"Unit test passes for getPlans endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-002",
|
||||||
|
"title": "Implement Subscription Status Endpoint",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Implement GET /api/v2/subscriptions/status endpoint to return current user's subscription status. Use SubscriptionManager.getSubscriptionDto() to get subscription info and return active status, start/finish dates.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"GET /api/v2/subscriptions/status returns 200 with subscription status",
|
||||||
|
"Returns active: true/false correctly",
|
||||||
|
"Returns start and finish dates when subscription is active",
|
||||||
|
"Endpoint requires authentication",
|
||||||
|
"Returns 401 for unauthenticated requests",
|
||||||
|
"Unit test passes for getStatus endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-003",
|
||||||
|
"title": "Implement Subscription Purchase Endpoint",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"estimated_hours": 3.0,
|
||||||
|
"description": "Implement POST /api/v2/subscriptions/purchase endpoint to handle subscription purchases. Accept planId in request body, validate plan exists, create payment via PaymentManager, and activate subscription for user.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"POST /api/v2/subscriptions/purchase accepts planId in request body",
|
||||||
|
"Validates planId exists and is valid",
|
||||||
|
"Creates payment record via PaymentManager",
|
||||||
|
"Activates subscription for user",
|
||||||
|
"Returns 200 with purchase confirmation",
|
||||||
|
"Returns 400 for invalid planId",
|
||||||
|
"Returns 401 for unauthenticated requests",
|
||||||
|
"Unit test passes for purchase endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"BACKEND-001"
|
||||||
|
],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-004",
|
||||||
|
"title": "Implement Subscription Cancel Endpoint",
|
||||||
|
"priority": "high",
|
||||||
|
"status": "pending",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Implement POST /api/v2/subscriptions/cancel endpoint to cancel user's active subscription. Update UserSubscriptionModel to mark subscription as cancelled, set cancellation date, and prevent auto-renewal.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"POST /api/v2/subscriptions/cancel cancels active subscription",
|
||||||
|
"Returns 200 with cancellation confirmation",
|
||||||
|
"Returns 400 if user has no active subscription",
|
||||||
|
"Returns 401 for unauthenticated requests",
|
||||||
|
"Subscription is marked as cancelled in database",
|
||||||
|
"Unit test passes for cancel endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"BACKEND-002"
|
||||||
|
],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-005",
|
||||||
|
"title": "Add Promocodes List Endpoint",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Implement GET /api/v2/promocodes endpoint to list available promocodes for current user. Return active campaigns with their promocodes that user can apply. Filter by active status and date range.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"GET /api/v2/promocodes returns 200 with list of available promocodes",
|
||||||
|
"Only returns active campaigns within date range",
|
||||||
|
"Filters promocodes user hasn't already activated",
|
||||||
|
"Returns 401 for unauthenticated requests",
|
||||||
|
"Proper JSON format with campaign and code information",
|
||||||
|
"Unit test passes for listPromocodes endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-006",
|
||||||
|
"title": "Add Promocode Validation Endpoint",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Implement GET /api/v2/promocodes/{code}/validate endpoint to validate a promocode without applying it. Check if code exists, is active, hasn't exceeded activation limits, and user is eligible.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"GET /api/v2/promocodes/{code}/validate returns validation result",
|
||||||
|
"Returns 200 with valid: true/false and message",
|
||||||
|
"Checks code existence, status, and activation limits",
|
||||||
|
"Checks user eligibility (tags, previous activations)",
|
||||||
|
"Returns 401 for unauthenticated requests",
|
||||||
|
"Returns 404 for non-existent codes",
|
||||||
|
"Unit test passes for validatePromocode endpoint"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart",
|
||||||
|
"mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart",
|
||||||
|
"mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-007",
|
||||||
|
"title": "Write Unit Tests for Statistics Models",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 3.0,
|
||||||
|
"description": "Write comprehensive unit tests for all statistics-related models (PackProgressModel, AchievementModel, StudySessionModel) including toDto/fromDto conversions, serialization, and edge cases.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Unit tests created for PackProgressModel",
|
||||||
|
"Unit tests created for AchievementModel",
|
||||||
|
"Unit tests created for StudySessionModel",
|
||||||
|
"All toDto/fromDto conversions tested",
|
||||||
|
"Serialization/deserialization tested",
|
||||||
|
"Edge cases covered (null values, empty lists, etc.)",
|
||||||
|
"15+ unit tests pass with >80% coverage"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/test/models/pack_progress_model_test.dart",
|
||||||
|
"mnemo_cards_backend/test/models/achievement_model_test.dart",
|
||||||
|
"mnemo_cards_backend/test/models/study_session_model_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-008",
|
||||||
|
"title": "Update OpenAPI Specification for Statistics Endpoints",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Update public/open_api.yaml to include all statistics endpoints (detailed, packs, words, timeline, sessions, achievements) with proper request/response schemas, query parameters, and examples.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"All 6 statistics endpoints documented in OpenAPI spec",
|
||||||
|
"Request schemas defined with query parameters",
|
||||||
|
"Response schemas defined with proper types",
|
||||||
|
"Examples provided for each endpoint",
|
||||||
|
"Error responses documented (400, 401, 404)",
|
||||||
|
"OpenAPI spec validates without errors"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/public/open_api.yaml"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-009",
|
||||||
|
"title": "Complete Statistics Integration Tests",
|
||||||
|
"priority": "medium",
|
||||||
|
"status": "in_progress",
|
||||||
|
"estimated_hours": 4.0,
|
||||||
|
"description": "Complete integration tests for all statistics endpoints, ensuring end-to-end flow works correctly. Test pagination, filtering, error handling, and edge cases for all statistics endpoints.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"Integration tests for getDetailedStatistics endpoint",
|
||||||
|
"Integration tests for getPacksStatistics with packId filter",
|
||||||
|
"Integration tests for getWordsStatistics with pagination and filters",
|
||||||
|
"Integration tests for getTimelineStatistics with period filtering",
|
||||||
|
"Integration tests for recordStudySession endpoint",
|
||||||
|
"Integration tests for getAchievements endpoint",
|
||||||
|
"All integration tests pass",
|
||||||
|
"Edge cases covered (empty data, invalid filters, etc.)"
|
||||||
|
],
|
||||||
|
"dependencies": [],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/test/api/v2/users_api_v2_statistics_test.dart"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "BACKEND-010",
|
||||||
|
"title": "Update Statistics Documentation",
|
||||||
|
"priority": "low",
|
||||||
|
"status": "pending",
|
||||||
|
"estimated_hours": 2.0,
|
||||||
|
"description": "Create STATISTICS_API.md documentation and update PROGRESS.md and TODO.md with statistics system completion status. Document API endpoints, request/response formats, and usage examples.",
|
||||||
|
"acceptance_criteria": [
|
||||||
|
"STATISTICS_API.md created with comprehensive API documentation",
|
||||||
|
"All statistics endpoints documented with examples",
|
||||||
|
"Request/response formats documented",
|
||||||
|
"PROGRESS.md updated with statistics completion status",
|
||||||
|
"TODO.md updated with completed tasks",
|
||||||
|
"Documentation is clear and complete"
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"BACKEND-008"
|
||||||
|
],
|
||||||
|
"files_to_modify": [
|
||||||
|
"mnemo_cards_backend/STATISTICS_API.md",
|
||||||
|
"mnemo_cards_backend/PROGRESS.md",
|
||||||
|
"mnemo_cards_backend/TODO.md"
|
||||||
|
],
|
||||||
|
"component": "backend"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"component": "web_v2",
|
"component": "web_v2",
|
||||||
"current_task_id": "WEB-00",
|
"current_task_id": "WEB-003",
|
||||||
"iteration_count": 1,
|
"iteration_count": 3,
|
||||||
"max_iterations": 10,
|
"max_iterations": 10,
|
||||||
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
"started_at": "2025-11-21T00:31:28.302997+00:00",
|
||||||
"last_commit": null,
|
"last_commit": null,
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@
|
||||||
"id": "WEB-001",
|
"id": "WEB-001",
|
||||||
"title": "Complete Ads Reward Flow - Integrate Adsgram SDK",
|
"title": "Complete Ads Reward Flow - Integrate Adsgram SDK",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"estimated_hours": 4.0,
|
"estimated_hours": 4.0,
|
||||||
"description": "Integrate the real Adsgram JavaScript SDK for web rewarded ads. The AdsRewardService, AdsRewardStateManager, and AdsRewardButton widget are already implemented, but the Adsgram stub needs to be replaced with real SDK integration. This includes loading the Adsgram SDK script, implementing proper ad lifecycle management, and handling ad completion/reward callbacks.",
|
"description": "Integrate the real Adsgram JavaScript SDK for web rewarded ads. The AdsRewardService, AdsRewardStateManager, and AdsRewardButton widget are already implemented, but the Adsgram stub needs to be replaced with real SDK integration. This includes loading the Adsgram SDK script, implementing proper ad lifecycle management, and handling ad completion/reward callbacks.",
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
|
|
@ -76,7 +76,7 @@
|
||||||
"id": "WEB-003",
|
"id": "WEB-003",
|
||||||
"title": "Add Buy Pack Button to PackDetailsPage",
|
"title": "Add Buy Pack Button to PackDetailsPage",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"estimated_hours": 2.0,
|
"estimated_hours": 2.0,
|
||||||
"description": "Add a 'Buy Pack' button to PackDetailsPage that navigates to PurchasePage when a pack requires purchase. This completes the pack purchase flow integration.",
|
"description": "Add a 'Buy Pack' button to PackDetailsPage that navigates to PurchasePage when a pack requires purchase. This completes the pack purchase flow integration.",
|
||||||
"acceptance_criteria": [
|
"acceptance_criteria": [
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,62 @@ class PromocodesApiV2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/v2/promocodes
|
||||||
|
/// Lists available promocodes for current user.
|
||||||
|
/// Returns active campaigns with promocodes that user can apply.
|
||||||
|
@Route.get('/promocodes')
|
||||||
|
Future<Response> listPromocodes(Request request) async {
|
||||||
|
final user = request.user;
|
||||||
|
if (user == null) {
|
||||||
|
return _unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
final campaigns =
|
||||||
|
await _promoCodesManager.listAvailablePromocodes(user);
|
||||||
|
return _json({
|
||||||
|
'campaigns': campaigns.map((c) => c.toJson()).toList(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// GET /api/v2/promocodes/{code}/validate
|
||||||
|
/// Validates a promocode without applying it.
|
||||||
|
/// Returns validation result with valid: true/false and message.
|
||||||
|
@Route.get('/promocodes/<code>/validate')
|
||||||
|
Future<Response> validatePromocode(
|
||||||
|
Request request,
|
||||||
|
String code,
|
||||||
|
) async {
|
||||||
|
final user = request.user;
|
||||||
|
if (user == null) {
|
||||||
|
return _unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.isEmpty) {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'error': 'bad_request',
|
||||||
|
'message': 'Promocode is required',
|
||||||
|
},
|
||||||
|
statusCode: 400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final result = await _promoCodesManager.validatePromocode(code, user);
|
||||||
|
|
||||||
|
// Return 404 if code not found
|
||||||
|
if (!result['valid'] && result['message'] == 'Промокод не найден') {
|
||||||
|
return _json(
|
||||||
|
{
|
||||||
|
'valid': false,
|
||||||
|
'message': result['message'],
|
||||||
|
},
|
||||||
|
statusCode: 404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _json(result);
|
||||||
|
}
|
||||||
|
|
||||||
/// POST /api/v2/promocodes/{code}/apply
|
/// POST /api/v2/promocodes/{code}/apply
|
||||||
/// Applies promocode for current user.
|
/// Applies promocode for current user.
|
||||||
@Route.post('/promocodes/<code>/apply')
|
@Route.post('/promocodes/<code>/apply')
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,16 @@ part of 'promocodes_api_v2.dart';
|
||||||
|
|
||||||
Router _$PromocodesApiV2Router(PromocodesApiV2 service) {
|
Router _$PromocodesApiV2Router(PromocodesApiV2 service) {
|
||||||
final router = Router();
|
final router = Router();
|
||||||
|
router.add(
|
||||||
|
'GET',
|
||||||
|
r'/promocodes',
|
||||||
|
service.listPromocodes,
|
||||||
|
);
|
||||||
|
router.add(
|
||||||
|
'GET',
|
||||||
|
r'/promocodes/<code>/validate',
|
||||||
|
service.validatePromocode,
|
||||||
|
);
|
||||||
router.add(
|
router.add(
|
||||||
'POST',
|
'POST',
|
||||||
r'/promocodes/<code>/apply',
|
r'/promocodes/<code>/apply',
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
import 'package:injectable/injectable.dart';
|
import 'package:injectable/injectable.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/authorize/helpers.dart';
|
||||||
import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart';
|
import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart';
|
||||||
import 'package:shelf/shelf.dart';
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:shelf_open_api/shelf_open_api.dart';
|
import 'package:shelf_open_api/shelf_open_api.dart';
|
||||||
|
|
@ -12,7 +14,9 @@ part 'subscriptions_api_v2.g.dart';
|
||||||
/// RESTful endpoints for managing subscriptions & plans
|
/// RESTful endpoints for managing subscriptions & plans
|
||||||
@lazySingleton
|
@lazySingleton
|
||||||
class SubscriptionsApiV2 {
|
class SubscriptionsApiV2 {
|
||||||
SubscriptionsApiV2(SubscriptionManager _subscriptionManager);
|
final SubscriptionManager _subscriptionManager;
|
||||||
|
|
||||||
|
SubscriptionsApiV2(this._subscriptionManager);
|
||||||
|
|
||||||
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
|
Response _ok(Object? object, {Map<String, String> headers = const {}}) =>
|
||||||
Response.ok(
|
Response.ok(
|
||||||
|
|
@ -28,13 +32,43 @@ class SubscriptionsApiV2 {
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
Response _internalServerError([String? message]) => Response(
|
||||||
|
500,
|
||||||
|
body: jsonEncode({
|
||||||
|
'error': 'Internal Server Error',
|
||||||
|
'message': message ?? 'An error occurred',
|
||||||
|
}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
|
Response _unauthorized() => Response(
|
||||||
|
401,
|
||||||
|
body: jsonEncode({
|
||||||
|
'error': 'unauthorized',
|
||||||
|
'message': 'Authentication required',
|
||||||
|
}),
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
);
|
||||||
|
|
||||||
/// GET /api/v2/subscriptions/plans
|
/// GET /api/v2/subscriptions/plans
|
||||||
/// List available subscription plans
|
/// List available subscription plans
|
||||||
|
/// Returns all available subscription plans. Authentication is optional.
|
||||||
@Route.get('/subscriptions/plans')
|
@Route.get('/subscriptions/plans')
|
||||||
@OpenApiRoute()
|
@OpenApiRoute()
|
||||||
Future<Response> getPlans(Request request) async {
|
Future<Response> getPlans(Request request) async {
|
||||||
// TODO: implement
|
try {
|
||||||
return _ok({'plans': []});
|
final plans = await _subscriptionManager.getAllSubscriptionPlans();
|
||||||
|
return _ok({
|
||||||
|
'plans': plans.map((plan) => plan.toJson()).toList(),
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
developer.log(
|
||||||
|
'Error in getPlans: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
);
|
||||||
|
return _internalServerError(e.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v2/subscriptions/purchase
|
/// POST /api/v2/subscriptions/purchase
|
||||||
|
|
@ -51,8 +85,27 @@ class SubscriptionsApiV2 {
|
||||||
@Route.get('/subscriptions/status')
|
@Route.get('/subscriptions/status')
|
||||||
@OpenApiRoute()
|
@OpenApiRoute()
|
||||||
Future<Response> getStatus(Request request) async {
|
Future<Response> getStatus(Request request) async {
|
||||||
// TODO: implement
|
try {
|
||||||
return _ok({'active': false});
|
final user = request.user;
|
||||||
|
if (user == null) {
|
||||||
|
return _unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
final subscriptionDto = await _subscriptionManager.getSubscriptionDto(user);
|
||||||
|
|
||||||
|
return _ok({
|
||||||
|
'active': subscriptionDto.isActive,
|
||||||
|
if (subscriptionDto.start != null) 'start': subscriptionDto.start!.toIso8601String(),
|
||||||
|
if (subscriptionDto.finish != null) 'finish': subscriptionDto.finish!.toIso8601String(),
|
||||||
|
});
|
||||||
|
} catch (e, s) {
|
||||||
|
developer.log(
|
||||||
|
'Error in getStatus: $e',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
);
|
||||||
|
return _internalServerError(e.toString());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// POST /api/v2/subscriptions/cancel
|
/// POST /api/v2/subscriptions/cancel
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,95 @@ class PromoCodesManager {
|
||||||
return dto;
|
return dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lists available promocodes for a user.
|
||||||
|
/// Returns active campaigns with promocodes that the user can apply.
|
||||||
|
Future<List<PromoCodesCampaignDto>> listAvailablePromocodes(
|
||||||
|
UserModel user,
|
||||||
|
) async {
|
||||||
|
await user.userData.load();
|
||||||
|
final userData = user.userData.value;
|
||||||
|
if (userData == null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
await userData.activatedPromoCodes.load();
|
||||||
|
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||||
|
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Get all active campaigns within date range
|
||||||
|
// Campaign is active if: now >= start && now <= finish
|
||||||
|
final activeCampaigns = await isar.txn(() async {
|
||||||
|
return await isar.promoCodesCampaignModels
|
||||||
|
.filter()
|
||||||
|
.statusEqualTo(PromoCodeCampaignModelStatus.active)
|
||||||
|
.startLessThan(now, include: true)
|
||||||
|
.finishGreaterThan(now, include: true)
|
||||||
|
.findAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
final availableCampaigns = <PromoCodesCampaignDto>[];
|
||||||
|
|
||||||
|
for (final campaign in activeCampaigns) {
|
||||||
|
// Check if campaign has tags and user matches them
|
||||||
|
if (campaign.tags.isNotEmpty &&
|
||||||
|
!campaign.tags.hasIntersection(userData.tags)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has already reached activationsPerUser limit
|
||||||
|
final userActivationsForCampaign = activatedPromoCodes
|
||||||
|
.where((p) => p.campaign.value?.id == campaign.id)
|
||||||
|
.length;
|
||||||
|
if (userActivationsForCampaign >= campaign.activationsPerUser) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load promocodes for this campaign
|
||||||
|
await campaign.promoCodes.load();
|
||||||
|
final campaignPromoCodes = campaign.promoCodes.toList();
|
||||||
|
|
||||||
|
// Filter available promocodes:
|
||||||
|
// - Not already activated by user
|
||||||
|
// - Not reached activationsPerCode limit
|
||||||
|
// - Not individual promocodes for other users
|
||||||
|
final availableCodes = <String>[];
|
||||||
|
for (final promoCode in campaignPromoCodes) {
|
||||||
|
// Skip if already activated by this user
|
||||||
|
if (activatedPromoCodes.contains(promoCode)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if reached activation limit
|
||||||
|
if (promoCode.activations >= campaign.activationsPerCode) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an individual promocode for another user
|
||||||
|
await promoCode.userData.load();
|
||||||
|
final codeUserData = promoCode.userData.value;
|
||||||
|
if (codeUserData != null) {
|
||||||
|
await codeUserData.user.load();
|
||||||
|
if (codeUserData.user.value?.id != user.id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
availableCodes.add(promoCode.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only include campaign if it has available codes
|
||||||
|
if (availableCodes.isNotEmpty) {
|
||||||
|
final dto = await campaign.toDto(withCodes: false);
|
||||||
|
availableCampaigns.add(
|
||||||
|
dto.copyWith(promoCodes: availableCodes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return availableCampaigns;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> createProductForAdsCampaignIfNeeded(
|
Future<void> createProductForAdsCampaignIfNeeded(
|
||||||
MnemoCardsProductModel product,
|
MnemoCardsProductModel product,
|
||||||
) async {
|
) async {
|
||||||
|
|
@ -126,6 +215,130 @@ class PromoCodesManager {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validates a promocode without applying it.
|
||||||
|
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
|
||||||
|
Future<Map<String, dynamic>> validatePromocode(
|
||||||
|
String code,
|
||||||
|
UserModel user,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final result = await isar.txn(() async {
|
||||||
|
final upperCode = code.toUpperCase();
|
||||||
|
|
||||||
|
// Check if code exists
|
||||||
|
final codeModel =
|
||||||
|
await isar.promoCodeModels.filter().codeEqualTo(upperCode).findFirst();
|
||||||
|
await codeModel?.campaign.load();
|
||||||
|
final campaign = codeModel?.campaign.value;
|
||||||
|
if (codeModel == null || campaign == null) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод не найден',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check campaign status and date range
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (campaign.status != PromoCodeCampaignModelStatus.active) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод недействителен',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (now.isBefore(campaign.start)) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод еще не активен',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (now.isAfter(campaign.finish)) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод истек',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if code has reached activation limit
|
||||||
|
if (codeModel.activations >= campaign.activationsPerCode) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод исчерпан',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load user data
|
||||||
|
await user.userData.load();
|
||||||
|
final userData = user.userData.value;
|
||||||
|
if (userData == null) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Ошибка при проверке промокода',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has already activated this code
|
||||||
|
await userData.activatedPromoCodes.load();
|
||||||
|
final activatedPromoCodes = userData.activatedPromoCodes;
|
||||||
|
if (activatedPromoCodes.contains(codeModel)) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод уже был активирован',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has reached activation limit for this campaign
|
||||||
|
// Load campaign relationships for activated promocodes
|
||||||
|
for (final activatedCode in activatedPromoCodes) {
|
||||||
|
await activatedCode.campaign.load();
|
||||||
|
}
|
||||||
|
if (activatedPromoCodes
|
||||||
|
.where((p) => p.campaign.value?.id == campaign.id)
|
||||||
|
.length >=
|
||||||
|
campaign.activationsPerUser) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Вы уже участвовали в этой акции',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check user tags match campaign tags
|
||||||
|
if (campaign.tags.isNotEmpty &&
|
||||||
|
!campaign.tags.hasIntersection(userData.tags)) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Промокод недействителен',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it's an individual promocode for another user
|
||||||
|
await codeModel.userData.load();
|
||||||
|
final codeUserData = codeModel.userData.value;
|
||||||
|
if (codeUserData != null) {
|
||||||
|
await codeUserData.user.load();
|
||||||
|
if (codeUserData.user.value?.id != user.id) {
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Это промокод для другого пользователя',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// All checks passed
|
||||||
|
return {
|
||||||
|
'valid': true,
|
||||||
|
'message': 'Промокод действителен',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
} catch (e, s) {
|
||||||
|
log('Error when validating promo code', error: e, stackTrace: s);
|
||||||
|
return {
|
||||||
|
'valid': false,
|
||||||
|
'message': 'Ошибка при проверке промокода',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
|
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
|
||||||
try {
|
try {
|
||||||
PromoCodeModel? checkedCodeModel;
|
PromoCodeModel? checkedCodeModel;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
1088
mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart
Normal file
1088
mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart
Normal file
File diff suppressed because it is too large
Load diff
480
mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart
Normal file
480
mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart
Normal file
|
|
@ -0,0 +1,480 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/v2/subscriptions_api_v2.dart';
|
||||||
|
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late Isar testIsar;
|
||||||
|
late SubscriptionManager subscriptionManager;
|
||||||
|
late SubscriptionsApiV2 subscriptionsApiV2;
|
||||||
|
late UserModel testUser;
|
||||||
|
|
||||||
|
setUpAll(() async {
|
||||||
|
// Initialize Isar for testing
|
||||||
|
await Isar.initializeIsarCore(download: true);
|
||||||
|
final testDir = Directory.systemTemp.createTempSync('isar_test_');
|
||||||
|
|
||||||
|
testIsar = await Isar.open(
|
||||||
|
[
|
||||||
|
CardPackModelSchema,
|
||||||
|
GameCardModelSchema,
|
||||||
|
UserModelSchema,
|
||||||
|
UserSubscriptionModelSchema,
|
||||||
|
SubscriptionPlanModelSchema,
|
||||||
|
TokenModelSchema,
|
||||||
|
RefreshTokenModelSchema,
|
||||||
|
TestModelSchema,
|
||||||
|
TestQuestionModelSchema,
|
||||||
|
PaymentModelSchema,
|
||||||
|
TaskModelSchema,
|
||||||
|
TestStatisticsModelSchema,
|
||||||
|
UserDataModelSchema,
|
||||||
|
PromoCodesCampaignModelSchema,
|
||||||
|
PromoCodeModelSchema,
|
||||||
|
DiscountCampaignModelSchema,
|
||||||
|
DiscountModelSchema,
|
||||||
|
],
|
||||||
|
directory: testDir.path,
|
||||||
|
name: 'test_db',
|
||||||
|
inspector: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
backend_main.isar = testIsar;
|
||||||
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
// Set up dependencies
|
||||||
|
subscriptionManager = SubscriptionManager();
|
||||||
|
subscriptionsApiV2 = SubscriptionsApiV2(subscriptionManager);
|
||||||
|
|
||||||
|
// Create test user
|
||||||
|
await testIsar.writeTxn(() async {
|
||||||
|
final user = UserModel.empty.copyWith(
|
||||||
|
id: 1,
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
);
|
||||||
|
await testIsar.userModels.put(user);
|
||||||
|
testUser = user;
|
||||||
|
|
||||||
|
// Create user data
|
||||||
|
final userData = UserDataModel()..user.value = user;
|
||||||
|
await testIsar.userDataModels.put(userData);
|
||||||
|
user.userData.value = userData;
|
||||||
|
await user.userData.save();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
// Clean up test data
|
||||||
|
await testIsar.writeTxn(() async {
|
||||||
|
await testIsar.subscriptionPlanModels.clear();
|
||||||
|
await testIsar.userSubscriptionModels.clear();
|
||||||
|
await testIsar.userDataModels.clear();
|
||||||
|
await testIsar.userModels.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDownAll(() async {
|
||||||
|
await testIsar.close(deleteFromDisk: true);
|
||||||
|
});
|
||||||
|
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 200 with list of plans when plans exist', () async {
|
||||||
|
// Create test subscription plans
|
||||||
|
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: 'Monthly Plan',
|
||||||
|
subtitle: '30 days access',
|
||||||
|
pricePerMonth: '299',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await testIsar.subscriptionPlanModels.put(plan1);
|
||||||
|
|
||||||
|
final plan2 = SubscriptionPlanModel(
|
||||||
|
id: 2,
|
||||||
|
price: '999',
|
||||||
|
currency: 'RUB',
|
||||||
|
durationDays: 90,
|
||||||
|
features: [
|
||||||
|
SubscriptionFeatureEnum.packs,
|
||||||
|
SubscriptionFeatureEnum.ads,
|
||||||
|
],
|
||||||
|
paymentSystem: PaymentSystem.yookassa,
|
||||||
|
paymentId: 'plan_2',
|
||||||
|
ui: SubscriptionPlanUI(
|
||||||
|
title: 'Quarterly Plan',
|
||||||
|
subtitle: '90 days access',
|
||||||
|
pricePerMonth: '333',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await testIsar.subscriptionPlanModels.put(plan2);
|
||||||
|
});
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['plans'], isA<List>());
|
||||||
|
expect(responseBody['plans'], hasLength(2));
|
||||||
|
|
||||||
|
final plans = responseBody['plans'] as List;
|
||||||
|
final plan0 = plans[0] as Map<String, dynamic>;
|
||||||
|
expect(plan0, containsPair('id', '1'));
|
||||||
|
expect(plan0, containsPair('price', '299'));
|
||||||
|
expect(plan0, containsPair('currency', 'RUB'));
|
||||||
|
expect(plan0, containsPair('durationDays', 30));
|
||||||
|
expect(plan0.containsKey('ui'), isTrue);
|
||||||
|
expect(plan0.containsKey('features'), isTrue);
|
||||||
|
|
||||||
|
expect(plans[1], containsPair('id', '2'));
|
||||||
|
expect(plans[1], containsPair('price', '999'));
|
||||||
|
expect(plans[1], containsPair('currency', 'RUB'));
|
||||||
|
expect(plans[1], containsPair('durationDays', 90));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should work with authenticated user', () async {
|
||||||
|
// Create test subscription plan
|
||||||
|
await testIsar.writeTxn(() async {
|
||||||
|
final plan = SubscriptionPlanModel(
|
||||||
|
id: 1,
|
||||||
|
price: '299',
|
||||||
|
currency: 'RUB',
|
||||||
|
durationDays: 30,
|
||||||
|
features: [SubscriptionFeatureEnum.packs],
|
||||||
|
paymentSystem: PaymentSystem.yookassa,
|
||||||
|
paymentId: 'plan_1',
|
||||||
|
ui: SubscriptionPlanUI(
|
||||||
|
title: 'Monthly Plan',
|
||||||
|
subtitle: '30 days access',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await testIsar.subscriptionPlanModels.put(plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/plans'),
|
||||||
|
).change(context: {'user': testUser});
|
||||||
|
|
||||||
|
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'], hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return properly formatted JSON', () async {
|
||||||
|
// Create test subscription plan
|
||||||
|
await testIsar.writeTxn(() async {
|
||||||
|
final plan = SubscriptionPlanModel(
|
||||||
|
id: 1,
|
||||||
|
price: '299',
|
||||||
|
currency: 'RUB',
|
||||||
|
durationDays: 30,
|
||||||
|
features: [SubscriptionFeatureEnum.packs],
|
||||||
|
paymentSystem: PaymentSystem.yookassa,
|
||||||
|
paymentId: 'plan_1',
|
||||||
|
ui: SubscriptionPlanUI(
|
||||||
|
title: 'Monthly Plan',
|
||||||
|
subtitle: '30 days access',
|
||||||
|
pricePerMonth: '299',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await testIsar.subscriptionPlanModels.put(plan);
|
||||||
|
});
|
||||||
|
|
||||||
|
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>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(response.headers['content-type'], contains('application/json'));
|
||||||
|
expect(responseBody.containsKey('plans'), isTrue);
|
||||||
|
|
||||||
|
final plans = responseBody['plans'] as List;
|
||||||
|
expect(plans, isNotEmpty);
|
||||||
|
|
||||||
|
final plan = plans[0] as Map<String, dynamic>;
|
||||||
|
// Verify required fields are present
|
||||||
|
expect(plan.containsKey('id'), isTrue);
|
||||||
|
expect(plan.containsKey('price'), isTrue);
|
||||||
|
expect(plan.containsKey('currency'), isTrue);
|
||||||
|
expect(plan.containsKey('durationDays'), isTrue);
|
||||||
|
expect(plan.containsKey('features'), isTrue);
|
||||||
|
expect(plan.containsKey('paymentSystem'), isTrue);
|
||||||
|
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',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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'),
|
||||||
|
);
|
||||||
|
|
||||||
|
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('SubscriptionsApiV2 - Get Status', () {
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/status'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await subscriptionsApiV2.getStatus(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
|
expect(responseBody['message'], equals('Authentication required'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return active: false when user has no subscription', () async {
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/status'),
|
||||||
|
).change(context: {'user': testUser});
|
||||||
|
|
||||||
|
final response = await subscriptionsApiV2.getStatus(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['active'], isFalse);
|
||||||
|
expect(responseBody.containsKey('start'), isFalse);
|
||||||
|
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));
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload user with subscription
|
||||||
|
final userWithSubscription = await testIsar.userModels.get(1);
|
||||||
|
expect(userWithSubscription, isNotNull);
|
||||||
|
await userWithSubscription!.subscriptionModel.load();
|
||||||
|
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/status'),
|
||||||
|
).change(context: {'user': userWithSubscription});
|
||||||
|
|
||||||
|
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();
|
||||||
|
final startDate = now.subtract(const Duration(days: 35));
|
||||||
|
final finishDate = now.subtract(const Duration(days: 5));
|
||||||
|
|
||||||
|
// Create expired 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reload user with subscription
|
||||||
|
final userWithSubscription = await testIsar.userModels.get(1);
|
||||||
|
expect(userWithSubscription, isNotNull);
|
||||||
|
await userWithSubscription!.subscriptionModel.load();
|
||||||
|
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/status'),
|
||||||
|
).change(context: {'user': userWithSubscription});
|
||||||
|
|
||||||
|
final response = await subscriptionsApiV2.getStatus(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['active'], isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return properly formatted JSON response', () async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final startDate = now.subtract(const Duration(days: 10));
|
||||||
|
final finishDate = now.add(const Duration(days: 20));
|
||||||
|
|
||||||
|
// Create active subscription
|
||||||
|
await testIsar.writeTxn(() async {
|
||||||
|
final subscription = UserSubscriptionModel(
|
||||||
|
start: startDate,
|
||||||
|
finish: finishDate,
|
||||||
|
features: [SubscriptionFeatureEnum.packs, SubscriptionFeatureEnum.ads],
|
||||||
|
);
|
||||||
|
await testIsar.userSubscriptionModels.put(subscription);
|
||||||
|
|
||||||
|
final user = await testIsar.userModels.get(1);
|
||||||
|
if (user != null) {
|
||||||
|
user.subscriptionModel.value = subscription;
|
||||||
|
await user.subscriptionModel.save();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
final userWithSubscription = await testIsar.userModels.get(1);
|
||||||
|
expect(userWithSubscription, isNotNull);
|
||||||
|
await userWithSubscription!.subscriptionModel.load();
|
||||||
|
|
||||||
|
final request = Request(
|
||||||
|
'GET',
|
||||||
|
Uri.parse('http://localhost/api/v2/subscriptions/status'),
|
||||||
|
).change(context: {'user': userWithSubscription});
|
||||||
|
|
||||||
|
final response = await subscriptionsApiV2.getStatus(request);
|
||||||
|
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),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
() => DateTime.parse(responseBody['finish'] as String),
|
||||||
|
returnsNormally,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,139 +1,864 @@
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:isar/isar.dart';
|
import 'package:isar/isar.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_handler.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/purchase/yoo_money.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/api/v2/users_api_v2.dart';
|
||||||
|
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/main.dart' as backend_main;
|
||||||
|
import 'package:mnemo_cards_backend/packs/pack_dto_converter.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart';
|
||||||
|
import 'package:mnemo_cards_backend/statistics/achievement_manager.dart';
|
||||||
|
import 'package:mnemo_cards_backend/statistics/session_tracker.dart';
|
||||||
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
import 'package:mnemo_cards_backend/statistics/statistics_calculator.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_data_model.dart';
|
import 'package:mnemo_cards_backend/user/user_data_model.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
import 'package:mnemo_cards_backend/user/user_manager.dart';
|
||||||
import 'package:mnemo_cards_backend/user/user_model.dart';
|
import 'package:mnemo_cards_backend/user/user_model.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:mnemo_cards_backend/packs/free_packs_distributor.dart';
|
||||||
|
import 'package:shelf/shelf.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late StatisticsCalculator statsCalculator;
|
late Isar testIsar;
|
||||||
|
late UserManager userManager;
|
||||||
|
late StatisticsCalculator statisticsCalculator;
|
||||||
|
late PaymentManager paymentManager;
|
||||||
|
late UsersApiV2 usersApiV2;
|
||||||
late UserModel testUser;
|
late UserModel testUser;
|
||||||
late UserDataModel testUserData;
|
late UserModel testUserWithoutData;
|
||||||
|
|
||||||
setUp(() {
|
Request buildRequest(
|
||||||
statsCalculator = StatisticsCalculator();
|
String method,
|
||||||
|
String url, {
|
||||||
|
UserModel? user,
|
||||||
|
String? body,
|
||||||
|
}) {
|
||||||
|
var request = Request(
|
||||||
|
method,
|
||||||
|
Uri.parse(url),
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
if (user != null) {
|
||||||
|
request = request.change(
|
||||||
|
context: {'user': user},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
// Create test user data with statistics
|
setUpAll(() async {
|
||||||
testUserData = UserDataModel(
|
// Initialize Isar for testing
|
||||||
words: [
|
await Isar.initializeIsarCore(download: true);
|
||||||
WordStatisticsModel(
|
final testDir = Directory.systemTemp.createTempSync('isar_test_');
|
||||||
word: 'hello',
|
|
||||||
correct: 5.0,
|
testIsar = await Isar.open(
|
||||||
incorrect: 1.0,
|
[
|
||||||
),
|
UserModelSchema,
|
||||||
WordStatisticsModel(
|
UserDataModelSchema,
|
||||||
word: 'world',
|
CardPackModelSchema,
|
||||||
correct: 3.0,
|
GameCardModelSchema,
|
||||||
incorrect: 2.0,
|
UserSubscriptionModelSchema,
|
||||||
),
|
SubscriptionPlanModelSchema,
|
||||||
],
|
TokenModelSchema,
|
||||||
packProgress: [
|
RefreshTokenModelSchema,
|
||||||
PackProgressModel(
|
TestModelSchema,
|
||||||
packId: 'basic_pack',
|
TestQuestionModelSchema,
|
||||||
totalCards: 20,
|
PaymentModelSchema,
|
||||||
learnedCards: 15,
|
TaskModelSchema,
|
||||||
studyTimeMinutes: 120,
|
TestStatisticsModelSchema,
|
||||||
lastStudyDate: DateTime.now().subtract(const Duration(days: 1)),
|
PromoCodesCampaignModelSchema,
|
||||||
),
|
PromoCodeModelSchema,
|
||||||
],
|
DiscountCampaignModelSchema,
|
||||||
studyDates: [
|
DiscountModelSchema,
|
||||||
DateTime.now(),
|
|
||||||
DateTime.now().subtract(const Duration(days: 1)),
|
|
||||||
DateTime.now().subtract(const Duration(days: 2)),
|
|
||||||
],
|
|
||||||
currentStreak: 3,
|
|
||||||
totalStudyTimeMinutes: 180,
|
|
||||||
achievements: [
|
|
||||||
AchievementModel(
|
|
||||||
id: 'first_words',
|
|
||||||
title: 'First Words',
|
|
||||||
description: 'Learned first words',
|
|
||||||
type: AchievementType.firstWordLearned,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
|
directory: testDir.path,
|
||||||
|
name: 'test_db',
|
||||||
|
inspector: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
testUser = UserModel(
|
backend_main.isar = testIsar;
|
||||||
email: 'test@example.com',
|
|
||||||
name: 'Test User',
|
|
||||||
)..userData.value = testUserData;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
setUp(() async {
|
||||||
|
statisticsCalculator = StatisticsCalculator();
|
||||||
|
|
||||||
group('StatisticsCalculator integration', () {
|
// Set up PaymentManager dependencies
|
||||||
test('calculateStreak works with user data', () {
|
final discountsManager = const DiscountsManager();
|
||||||
final streak = statsCalculator.calculateStreak(testUserData.studyDates);
|
final productsPriceResolver = ProductsPriceResolver(discountsManager);
|
||||||
expect(streak, 3);
|
final adsManager = AdsManager();
|
||||||
});
|
final packDtoConverter = PackDtoConverter(
|
||||||
|
productsPriceResolver,
|
||||||
|
adsManager,
|
||||||
|
);
|
||||||
|
final packManager = PackManager(packDtoConverter);
|
||||||
|
final subscriptionManager = SubscriptionManager();
|
||||||
|
final yooMoneyHandler = YooMoneyHandler(packManager);
|
||||||
|
final rustorePurchaseHandler = RustorePurchaseHandler();
|
||||||
|
paymentManager = PaymentManager(
|
||||||
|
packManager,
|
||||||
|
subscriptionManager,
|
||||||
|
yooMoneyHandler,
|
||||||
|
rustorePurchaseHandler,
|
||||||
|
productsPriceResolver,
|
||||||
|
);
|
||||||
|
|
||||||
test('calculateTotalStudyTime works with user data', () {
|
userManager = UserManager(
|
||||||
final totalTime = statsCalculator.calculateTotalStudyTime(testUserData);
|
const FreePacksDistributor(),
|
||||||
expect(totalTime, 120); // From pack progress
|
SessionTracker(testIsar),
|
||||||
});
|
statisticsCalculator,
|
||||||
|
AchievementManager(testIsar),
|
||||||
|
);
|
||||||
|
usersApiV2 = UsersApiV2(
|
||||||
|
userManager,
|
||||||
|
paymentManager,
|
||||||
|
statisticsCalculator,
|
||||||
|
);
|
||||||
|
|
||||||
test('calculatePackProgress works with user data', () {
|
// Create test user with statistics data
|
||||||
final packProgress = statsCalculator.calculatePackProgress(testUser, 'basic_pack');
|
await testIsar.writeTxn(() async {
|
||||||
expect(packProgress.packId, 'basic_pack');
|
final user = UserModel(
|
||||||
expect(packProgress.totalCards, 20);
|
id: 1,
|
||||||
expect(packProgress.learnedCards, 15);
|
email: 'test@example.com',
|
||||||
});
|
name: 'Test User',
|
||||||
|
);
|
||||||
|
await testIsar.userModels.put(user);
|
||||||
|
|
||||||
test('getTimelineStatistics returns valid structure', () {
|
final userData = UserDataModel(
|
||||||
final timeline = statsCalculator.getTimelineStatistics(testUserData, period: 'week');
|
words: [
|
||||||
|
WordStatisticsModel(
|
||||||
|
word: 'hello',
|
||||||
|
correct: 10.0,
|
||||||
|
incorrect: 2.0,
|
||||||
|
skipped: 1.0,
|
||||||
|
),
|
||||||
|
WordStatisticsModel(
|
||||||
|
word: 'world',
|
||||||
|
correct: 5.0,
|
||||||
|
incorrect: 5.0,
|
||||||
|
skipped: 0.0,
|
||||||
|
),
|
||||||
|
WordStatisticsModel(
|
||||||
|
word: 'test',
|
||||||
|
correct: 8.0,
|
||||||
|
incorrect: 1.0,
|
||||||
|
skipped: 0.0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
packProgress: [
|
||||||
|
PackProgressModel(
|
||||||
|
packId: 'pack1',
|
||||||
|
totalCards: 20,
|
||||||
|
learnedCards: 15,
|
||||||
|
studyTimeMinutes: 120,
|
||||||
|
lastStudyDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
),
|
||||||
|
PackProgressModel(
|
||||||
|
packId: 'pack2',
|
||||||
|
totalCards: 30,
|
||||||
|
learnedCards: 10,
|
||||||
|
studyTimeMinutes: 60,
|
||||||
|
lastStudyDate: DateTime.now().subtract(const Duration(days: 3)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
studyDates: [
|
||||||
|
DateTime.now(),
|
||||||
|
DateTime.now().subtract(const Duration(days: 1)),
|
||||||
|
DateTime.now().subtract(const Duration(days: 2)),
|
||||||
|
DateTime.now().subtract(const Duration(days: 5)),
|
||||||
|
],
|
||||||
|
currentStreak: 3,
|
||||||
|
longestStreak: 5,
|
||||||
|
totalStudyTimeMinutes: 180,
|
||||||
|
achievements: [
|
||||||
|
AchievementModel(
|
||||||
|
id: 'first_words',
|
||||||
|
title: 'First Words',
|
||||||
|
description: 'Learned first words',
|
||||||
|
type: AchievementType.firstWordLearned,
|
||||||
|
),
|
||||||
|
AchievementModel(
|
||||||
|
id: 'streak_3',
|
||||||
|
title: '3 Day Streak',
|
||||||
|
description: 'Studied 3 days in a row',
|
||||||
|
type: AchievementType.streak3Days,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)..user.value = user;
|
||||||
|
|
||||||
expect(timeline['period'], 'week');
|
// Save userData first
|
||||||
expect(timeline['totalDays'], greaterThan(0));
|
await testIsar.userDataModels.put(userData);
|
||||||
expect(timeline['activeDays'], greaterThan(0));
|
// Then link it to user
|
||||||
expect(timeline['dailyActivity'], isMap);
|
user.userData.value = userData;
|
||||||
expect(timeline['studyDates'], isList);
|
await user.userData.save();
|
||||||
});
|
// Save user again to persist the link
|
||||||
|
await testIsar.userModels.put(user);
|
||||||
|
|
||||||
test('calculateAchievementProgress generates achievements', () {
|
// Reload user to ensure userData link is properly loaded
|
||||||
final achievements = statsCalculator.calculateAchievementProgress(testUserData);
|
testUser = (await testIsar.userModels.get(1))!;
|
||||||
expect(achievements, isNotEmpty);
|
await testUser.userData.load();
|
||||||
expect(achievements.first.id, isNotEmpty);
|
|
||||||
expect(achievements.first.title, isNotEmpty);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('findDifficultWords returns sorted words', () {
|
// Create user without data
|
||||||
final difficultWords = statsCalculator.findDifficultWords(testUserData, limit: 5);
|
final userWithoutData = UserModel(
|
||||||
|
id: 2,
|
||||||
expect(difficultWords.length, 2); // We have 2 words in test data
|
email: 'nodata@example.com',
|
||||||
// Should be sorted by difficulty (higher incorrect ratio first)
|
name: 'No Data User',
|
||||||
expect(difficultWords[0].word, 'world'); // More incorrect answers
|
);
|
||||||
expect(difficultWords[1].word, 'hello'); // Fewer incorrect answers
|
await testIsar.userModels.put(userWithoutData);
|
||||||
|
testUserWithoutData = userWithoutData;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
group('UserDataDto conversion', () {
|
tearDown(() async {
|
||||||
test('toDto converts UserDataModel with statistics', () {
|
// Clean up test data
|
||||||
final dto = testUserData.toDto();
|
await testIsar.writeTxn(() async {
|
||||||
|
await testIsar.userModels.clear();
|
||||||
|
await testIsar.userDataModels.clear();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(dto.totalStudyTimeMinutes, 180);
|
tearDownAll(() async {
|
||||||
expect(dto.currentStreak, 3);
|
await testIsar.close(deleteFromDisk: true);
|
||||||
expect(dto.packProgress.length, 1);
|
});
|
||||||
expect(dto.studyDates.length, 3);
|
|
||||||
expect(dto.achievements.length, 1);
|
group('getDetailedStatistics', () {
|
||||||
expect(dto.allWordsStatistics?.words.length, 2);
|
test('should return detailed statistics for authenticated user', () async {
|
||||||
|
// Fetch user through UserManager to ensure links are loaded
|
||||||
|
final fetchedUser = await userManager.fetchUser(1);
|
||||||
|
expect(fetchedUser, isNotNull);
|
||||||
|
if (fetchedUser != null) {
|
||||||
|
await fetchedUser.userData.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/detailed',
|
||||||
|
user: fetchedUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getDetailedStatistics(request);
|
||||||
|
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) {
|
||||||
|
// UserData not found is a valid scenario
|
||||||
|
expect(responseBody.containsKey('error'), isTrue);
|
||||||
|
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') &&
|
||||||
|
responseBody['totalStudyTimeMinutes'] != null) {
|
||||||
|
expect(responseBody['totalStudyTimeMinutes'], equals(180));
|
||||||
|
}
|
||||||
|
if (responseBody.containsKey('currentStreak') &&
|
||||||
|
responseBody['currentStreak'] != null) {
|
||||||
|
expect(responseBody['currentStreak'], equals(3));
|
||||||
|
}
|
||||||
|
if (responseBody.containsKey('longestStreak') &&
|
||||||
|
responseBody['longestStreak'] != null) {
|
||||||
|
expect(responseBody['longestStreak'], equals(5));
|
||||||
|
}
|
||||||
|
if (responseBody.containsKey('packProgress')) {
|
||||||
|
expect(responseBody['packProgress'], isA<Map>());
|
||||||
|
if (responseBody['packProgress'] != null) {
|
||||||
|
expect((responseBody['packProgress'] as Map).length, equals(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseBody.containsKey('studyDates')) {
|
||||||
|
expect(responseBody['studyDates'], isA<List>());
|
||||||
|
if (responseBody['studyDates'] != null) {
|
||||||
|
expect((responseBody['studyDates'] as List).length, equals(4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (responseBody.containsKey('achievements')) {
|
||||||
|
expect(responseBody['achievements'], isA<List>());
|
||||||
|
if (responseBody['achievements'] != null) {
|
||||||
|
expect((responseBody['achievements'] as List).length, equals(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('PackProgressDto converts correctly', () {
|
test('should return 401 for unauthenticated request', () async {
|
||||||
final packDto = testUserData.packProgress[0].toDto();
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/detailed',
|
||||||
|
);
|
||||||
|
|
||||||
expect(packDto.packId, 'basic_pack');
|
final response = await usersApiV2.getDetailedStatistics(request);
|
||||||
expect(packDto.totalCards, 20);
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
expect(packDto.learnedCards, 15);
|
as Map<String, dynamic>;
|
||||||
expect(packDto.studyTimeMinutes, 120);
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('AchievementDto converts correctly', () {
|
test('should return 404 for user without data', () async {
|
||||||
final achievementDto = testUserData.achievements[0].toDto();
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/detailed',
|
||||||
|
user: testUserWithoutData,
|
||||||
|
);
|
||||||
|
|
||||||
expect(achievementDto.id, 'first_words');
|
final response = await usersApiV2.getDetailedStatistics(request);
|
||||||
expect(achievementDto.title, 'First Words');
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
expect(achievementDto.type, AchievementType.firstWordLearned);
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(404));
|
||||||
|
expect(responseBody['error'], equals('user_data_not_found'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getPacksStatistics', () {
|
||||||
|
test('should return all packs statistics', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/packs',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getPacksStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody.length, equals(2));
|
||||||
|
expect(responseBody[0]['packId'], isA<String>());
|
||||||
|
expect(responseBody[0]['totalCards'], isA<int>());
|
||||||
|
expect(responseBody[0]['learnedCards'], isA<int>());
|
||||||
|
expect(responseBody[0]['studyTimeMinutes'], isA<int>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by packId', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/packs?packId=pack1',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getPacksStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody.length, equals(1));
|
||||||
|
expect(responseBody[0]['packId'], equals('pack1'));
|
||||||
|
expect(responseBody[0]['totalCards'], equals(20));
|
||||||
|
expect(responseBody[0]['learnedCards'], equals(15));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty list for non-existent packId', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/packs?packId=nonexistent',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getPacksStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody.length, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty list for user without data', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/packs',
|
||||||
|
user: testUserWithoutData,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getPacksStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody.length, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/packs',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getPacksStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getWordsStatistics', () {
|
||||||
|
test('should return paginated words statistics', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?limit=10&offset=0',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['words'], isA<List>());
|
||||||
|
expect(responseBody['totalCount'], equals(3));
|
||||||
|
expect(responseBody['page'], equals(0));
|
||||||
|
expect(responseBody['pageSize'], equals(10));
|
||||||
|
expect(responseBody['hasMore'], equals(false));
|
||||||
|
expect((responseBody['words'] as List).length, equals(3));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should paginate correctly', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?limit=2&offset=0',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect((responseBody['words'] as List).length, equals(2));
|
||||||
|
expect(responseBody['hasMore'], equals(true));
|
||||||
|
|
||||||
|
// Get second page
|
||||||
|
final request2 = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?limit=2&offset=2',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response2 = await usersApiV2.getWordsStatistics(request2);
|
||||||
|
final responseBody2 = jsonDecode(await response2.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(responseBody2['totalCount'], equals(3));
|
||||||
|
expect((responseBody2['words'] as List).length, equals(1));
|
||||||
|
expect(responseBody2['hasMore'], equals(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by difficulty', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?sortBy=difficulty',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
final words = responseBody['words'] as List;
|
||||||
|
expect(words.length, equals(3));
|
||||||
|
// Verify that all words are returned and have difficultyScore field
|
||||||
|
for (final word in words) {
|
||||||
|
expect(word['word'], isA<String>());
|
||||||
|
expect(word['difficultyScore'], isA<double>());
|
||||||
|
expect(word['correct'], isA<double>());
|
||||||
|
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();
|
||||||
|
for (var i = 0; i < difficulties.length - 1; i++) {
|
||||||
|
expect(difficulties[i], greaterThanOrEqualTo(difficulties[i + 1]),
|
||||||
|
reason: 'Words should be sorted by difficulty descending');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by accuracy', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?sortBy=accuracy',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
final words = responseBody['words'] as List;
|
||||||
|
expect(words.length, greaterThan(0));
|
||||||
|
// Should be sorted by accuracy (ascending - lower accuracy first)
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should sort by recent', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?sortBy=recent',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['words'], isA<List>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by needsReview', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?needsReview=true',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
final words = responseBody['words'] as List;
|
||||||
|
// Words needing review should have high incorrect ratio
|
||||||
|
// 'world' has 50% accuracy, so should need review
|
||||||
|
expect(words.length, greaterThanOrEqualTo(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should use default pagination when not specified', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['pageSize'], equals(50)); // Default limit
|
||||||
|
expect(responseBody['page'], equals(0)); // Default offset
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should clamp limit to maximum 100', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?limit=200',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['pageSize'], lessThanOrEqualTo(100));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle negative offset', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words?offset=-10',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['page'], equals(0)); // Should clamp to 0
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty result for user without data', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words',
|
||||||
|
user: testUserWithoutData,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['words'], isA<List>());
|
||||||
|
expect((responseBody['words'] as List).length, equals(0));
|
||||||
|
expect(responseBody['totalCount'], equals(0));
|
||||||
|
expect(responseBody['hasMore'], equals(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/words',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getWordsStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getTimelineStatistics', () {
|
||||||
|
test('should return timeline statistics with default period', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['period'], equals('month'));
|
||||||
|
expect(responseBody['totalDays'], isA<int>());
|
||||||
|
expect(responseBody['activeDays'], isA<int>());
|
||||||
|
expect(responseBody['totalMinutes'], isA<int>());
|
||||||
|
expect(responseBody['averageDailyMinutes'], isA<double>());
|
||||||
|
expect(responseBody['currentStreak'], isA<int>());
|
||||||
|
expect(responseBody['dailyActivity'], isA<Map>());
|
||||||
|
expect(responseBody['studyDates'], isA<List>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by period', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline?period=week',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['period'], equals('week'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should filter by date range', () async {
|
||||||
|
final from = DateTime.now().subtract(const Duration(days: 7));
|
||||||
|
final to = DateTime.now();
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline?from=${from.toIso8601String()}&to=${to.toIso8601String()}',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['period'], isA<String>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle invalid date format gracefully', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline?from=invalid-date',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
// Should still return valid response, ignoring invalid date
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['period'], isA<String>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty statistics for user without data', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline',
|
||||||
|
user: testUserWithoutData,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['totalDays'], equals(0));
|
||||||
|
expect(responseBody['activeDays'], equals(0));
|
||||||
|
expect(responseBody['totalMinutes'], equals(0));
|
||||||
|
expect(responseBody['averageDailyMinutes'], equals(0.0));
|
||||||
|
expect(responseBody['currentStreak'], equals(0));
|
||||||
|
expect(responseBody['dailyActivity'], isA<Map>());
|
||||||
|
expect((responseBody['dailyActivity'] as Map).isEmpty, isTrue);
|
||||||
|
expect(responseBody['studyDates'], isA<List>());
|
||||||
|
expect((responseBody['studyDates'] as List).isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/statistics/timeline',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getTimelineStatistics(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('recordStudySession', () {
|
||||||
|
test('should record study session successfully', () async {
|
||||||
|
final sessionDto = StudySessionDto.start(
|
||||||
|
sessionId: 'test-session-1',
|
||||||
|
packId: 'pack1',
|
||||||
|
);
|
||||||
|
final request = buildRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/users/me/sessions',
|
||||||
|
user: testUser,
|
||||||
|
body: jsonEncode(sessionDto.toJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.recordStudySession(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody['result'], equals(true));
|
||||||
|
expect(responseBody['sessionId'], equals('test-session-1'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 for empty request body', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/users/me/sessions',
|
||||||
|
user: testUser,
|
||||||
|
body: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.recordStudySession(request);
|
||||||
|
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('Session data is required'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 for invalid JSON', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/users/me/sessions',
|
||||||
|
user: testUser,
|
||||||
|
body: 'invalid json',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.recordStudySession(request);
|
||||||
|
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 session data'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 400 for invalid session data', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/users/me/sessions',
|
||||||
|
user: testUser,
|
||||||
|
body: jsonEncode({'invalid': 'data'}),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.recordStudySession(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(400));
|
||||||
|
expect(responseBody['error'], equals('bad_request'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final sessionDto = StudySessionDto.start(
|
||||||
|
sessionId: 'test-session-1',
|
||||||
|
packId: 'pack1',
|
||||||
|
);
|
||||||
|
final request = buildRequest(
|
||||||
|
'POST',
|
||||||
|
'http://localhost/api/v2/users/me/sessions',
|
||||||
|
body: jsonEncode(sessionDto.toJson()),
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.recordStudySession(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('getAchievements', () {
|
||||||
|
test('should return user achievements', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/achievements',
|
||||||
|
user: testUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getAchievements(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody, isA<List>());
|
||||||
|
expect(responseBody.length, greaterThan(0));
|
||||||
|
expect(responseBody[0]['id'], isA<String>());
|
||||||
|
expect(responseBody[0]['title'], isA<String>());
|
||||||
|
expect(responseBody[0]['type'], isA<String>());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return empty list for user without data', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/achievements',
|
||||||
|
user: testUserWithoutData,
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getAchievements(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString()) as List;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(200));
|
||||||
|
expect(responseBody, isA<List>());
|
||||||
|
expect(responseBody.length, equals(0));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should return 401 for unauthenticated request', () async {
|
||||||
|
final request = buildRequest(
|
||||||
|
'GET',
|
||||||
|
'http://localhost/api/v2/users/me/achievements',
|
||||||
|
);
|
||||||
|
|
||||||
|
final response = await usersApiV2.getAchievements(request);
|
||||||
|
final responseBody = jsonDecode(await response.readAsString())
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
|
||||||
|
expect(response.statusCode, equals(401));
|
||||||
|
expect(responseBody['error'], equals('unauthorized'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
590
mnemo_cards_backend/test/models/achievement_model_test.dart
Normal file
590
mnemo_cards_backend/test/models/achievement_model_test.dart
Normal file
|
|
@ -0,0 +1,590 @@
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AchievementModel', () {
|
||||||
|
group('constructor', () {
|
||||||
|
test('creates model with default values', () {
|
||||||
|
final model = AchievementModel();
|
||||||
|
|
||||||
|
expect(model.id, '');
|
||||||
|
expect(model.title, '');
|
||||||
|
expect(model.description, '');
|
||||||
|
expect(model.iconUrl, isNull);
|
||||||
|
expect(model.unlockedAt, isNull);
|
||||||
|
expect(model.type, AchievementType.firstWordLearned);
|
||||||
|
expect(model.progress, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with all parameters', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test_achievement',
|
||||||
|
title: 'Test Achievement',
|
||||||
|
description: 'Test Description',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
unlockedAt: now,
|
||||||
|
type: AchievementType.streak7Days,
|
||||||
|
progress: 0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.id, 'test_achievement');
|
||||||
|
expect(model.title, 'Test Achievement');
|
||||||
|
expect(model.description, 'Test Description');
|
||||||
|
expect(model.iconUrl, 'https://example.com/icon.png');
|
||||||
|
expect(model.unlockedAt, now);
|
||||||
|
expect(model.type, AchievementType.streak7Days);
|
||||||
|
expect(model.progress, 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with null iconUrl', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.iconUrl, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with null unlockedAt', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.unlockedAt, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto', () {
|
||||||
|
test('converts model to DTO correctly', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test_achievement',
|
||||||
|
title: 'Test Achievement',
|
||||||
|
description: 'Test Description',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
unlockedAt: now,
|
||||||
|
type: AchievementType.words100Learned,
|
||||||
|
progress: 0.85,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.id, 'test_achievement');
|
||||||
|
expect(dto.title, 'Test Achievement');
|
||||||
|
expect(dto.description, 'Test Description');
|
||||||
|
expect(dto.iconUrl, 'https://example.com/icon.png');
|
||||||
|
expect(dto.unlockedAt, now);
|
||||||
|
expect(dto.type, AchievementType.words100Learned);
|
||||||
|
expect(dto.progress, 0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts model with null values to DTO', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.iconUrl, isNull);
|
||||||
|
expect(dto.unlockedAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts all achievement types correctly', () {
|
||||||
|
for (final type in AchievementType.values) {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.type, type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromDto', () {
|
||||||
|
test('creates model from DTO correctly', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final dto = AchievementDto(
|
||||||
|
id: 'test_achievement',
|
||||||
|
title: 'Test Achievement',
|
||||||
|
description: 'Test Description',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
unlockedAt: now,
|
||||||
|
type: AchievementType.streak30Days,
|
||||||
|
progress: 0.9,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = AchievementModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.id, 'test_achievement');
|
||||||
|
expect(model.title, 'Test Achievement');
|
||||||
|
expect(model.description, 'Test Description');
|
||||||
|
expect(model.iconUrl, 'https://example.com/icon.png');
|
||||||
|
expect(model.unlockedAt, now);
|
||||||
|
expect(model.type, AchievementType.streak30Days);
|
||||||
|
expect(model.progress, 0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model from DTO with null values', () {
|
||||||
|
final dto = AchievementDto(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.firstWordLearned,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = AchievementModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.iconUrl, isNull);
|
||||||
|
expect(model.unlockedAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model from DTO for all achievement types', () {
|
||||||
|
for (final type in AchievementType.values) {
|
||||||
|
final dto = AchievementDto(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = AchievementModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.type, type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto and fromDto roundtrip', () {
|
||||||
|
test('preserves all data through conversion', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
final original = AchievementModel(
|
||||||
|
id: 'test_achievement',
|
||||||
|
title: 'Test Achievement',
|
||||||
|
description: 'Test Description',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
unlockedAt: now,
|
||||||
|
type: AchievementType.perfectTestScore,
|
||||||
|
progress: 0.95,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = original.toDto();
|
||||||
|
final restored = AchievementModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(restored.id, original.id);
|
||||||
|
expect(restored.title, original.title);
|
||||||
|
expect(restored.description, original.description);
|
||||||
|
expect(restored.iconUrl, original.iconUrl);
|
||||||
|
expect(restored.unlockedAt, original.unlockedAt);
|
||||||
|
expect(restored.type, original.type);
|
||||||
|
expect(restored.progress, original.progress);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isUnlocked', () {
|
||||||
|
test('returns false when unlockedAt is null', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isUnlocked, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when unlockedAt is set', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
unlockedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isUnlocked, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isLocked', () {
|
||||||
|
test('returns true when unlockedAt is null', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isLocked, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when unlockedAt is set', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
unlockedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isLocked, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('unlock', () {
|
||||||
|
test('sets unlockedAt to current time', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final beforeUnlock = DateTime.now();
|
||||||
|
final unlocked = model.unlock();
|
||||||
|
final afterUnlock = DateTime.now();
|
||||||
|
|
||||||
|
expect(unlocked.unlockedAt, isNotNull);
|
||||||
|
expect(
|
||||||
|
unlocked.unlockedAt!.isAfter(beforeUnlock.subtract(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
unlocked.unlockedAt!.isBefore(afterUnlock.add(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets progress to 1.0', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
progress: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final unlocked = model.unlock();
|
||||||
|
|
||||||
|
expect(unlocked.progress, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other fields', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
type: AchievementType.streak7Days,
|
||||||
|
);
|
||||||
|
|
||||||
|
final unlocked = model.unlock();
|
||||||
|
|
||||||
|
expect(unlocked.id, model.id);
|
||||||
|
expect(unlocked.title, model.title);
|
||||||
|
expect(unlocked.description, model.description);
|
||||||
|
expect(unlocked.iconUrl, model.iconUrl);
|
||||||
|
expect(unlocked.type, model.type);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateProgress', () {
|
||||||
|
test('updates progress correctly', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
progress: 0.3,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateProgress(0.7);
|
||||||
|
|
||||||
|
expect(updated.progress, 0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps progress to 0.0 minimum', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateProgress(-0.5);
|
||||||
|
|
||||||
|
expect(updated.progress, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps progress to 1.0 maximum', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateProgress(1.5);
|
||||||
|
|
||||||
|
expect(updated.progress, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves unlockedAt when updating progress', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
unlockedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateProgress(0.5);
|
||||||
|
|
||||||
|
expect(updated.unlockedAt, now);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other fields', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
iconUrl: 'https://example.com/icon.png',
|
||||||
|
type: AchievementType.words50Learned,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateProgress(0.6);
|
||||||
|
|
||||||
|
expect(updated.id, model.id);
|
||||||
|
expect(updated.title, model.title);
|
||||||
|
expect(updated.description, model.description);
|
||||||
|
expect(updated.iconUrl, model.iconUrl);
|
||||||
|
expect(updated.type, model.type);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('category', () {
|
||||||
|
test('returns correct category for first_steps achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.firstWordLearned,
|
||||||
|
).category,
|
||||||
|
'first_steps',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.firstTestCompleted,
|
||||||
|
).category,
|
||||||
|
'first_steps',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.firstPackCompleted,
|
||||||
|
).category,
|
||||||
|
'first_steps',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for streaks achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.streak3Days,
|
||||||
|
).category,
|
||||||
|
'streaks',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.streak100Days,
|
||||||
|
).category,
|
||||||
|
'streaks',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for words_mastery achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.words10Learned,
|
||||||
|
).category,
|
||||||
|
'words_mastery',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.words1000Learned,
|
||||||
|
).category,
|
||||||
|
'words_mastery',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for performance achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.perfectTestScore,
|
||||||
|
).category,
|
||||||
|
'performance',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.speedLearner,
|
||||||
|
).category,
|
||||||
|
'performance',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for dedication achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.dedicatedLearner,
|
||||||
|
).category,
|
||||||
|
'dedication',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for time_based achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.nightOwl,
|
||||||
|
).category,
|
||||||
|
'time_based',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.earlyBird,
|
||||||
|
).category,
|
||||||
|
'time_based',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correct category for special achievements', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.consistentLearner,
|
||||||
|
).category,
|
||||||
|
'special',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: AchievementType.languageMaster,
|
||||||
|
).category,
|
||||||
|
'special',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('edge cases', () {
|
||||||
|
test('handles empty strings', () {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: '',
|
||||||
|
title: '',
|
||||||
|
description: '',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.id, '');
|
||||||
|
expect(model.title, '');
|
||||||
|
expect(model.description, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles very long strings', () {
|
||||||
|
final longString = 'a' * 1000;
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: longString,
|
||||||
|
title: longString,
|
||||||
|
description: longString,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.id, longString);
|
||||||
|
expect(model.title, longString);
|
||||||
|
expect(model.description, longString);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles progress at boundaries', () {
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
progress: 0.0,
|
||||||
|
).progress,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
progress: 1.0,
|
||||||
|
).progress,
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles all achievement types', () {
|
||||||
|
for (final type in AchievementType.values) {
|
||||||
|
final model = AchievementModel(
|
||||||
|
id: 'test',
|
||||||
|
title: 'Test',
|
||||||
|
description: 'Test',
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.type, type);
|
||||||
|
expect(model.category, isNotEmpty);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
415
mnemo_cards_backend/test/models/pack_progress_model_test.dart
Normal file
415
mnemo_cards_backend/test/models/pack_progress_model_test.dart
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PackProgressModel', () {
|
||||||
|
group('constructor', () {
|
||||||
|
test('creates model with default values', () {
|
||||||
|
final model = PackProgressModel();
|
||||||
|
|
||||||
|
expect(model.packId, '');
|
||||||
|
expect(model.totalCards, 0);
|
||||||
|
expect(model.learnedCards, 0);
|
||||||
|
expect(model.studyTimeMinutes, 0);
|
||||||
|
expect(model.lastStudyDate, isNull);
|
||||||
|
expect(model.firstStudyDate, isNull);
|
||||||
|
expect(model.cardAttempts, isEmpty);
|
||||||
|
expect(model.averageAccuracy, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with all parameters', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final yesterday = now.subtract(const Duration(days: 1));
|
||||||
|
final cardAttempts = {'card1': 3, 'card2': 5};
|
||||||
|
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 20,
|
||||||
|
learnedCards: 15,
|
||||||
|
studyTimeMinutes: 120,
|
||||||
|
lastStudyDate: now,
|
||||||
|
firstStudyDate: yesterday,
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
averageAccuracy: 0.85,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.packId, 'test_pack');
|
||||||
|
expect(model.totalCards, 20);
|
||||||
|
expect(model.learnedCards, 15);
|
||||||
|
expect(model.studyTimeMinutes, 120);
|
||||||
|
expect(model.lastStudyDate, now);
|
||||||
|
expect(model.firstStudyDate, yesterday);
|
||||||
|
expect(model.cardAttempts, cardAttempts);
|
||||||
|
expect(model.averageAccuracy, 0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with empty cardAttempts map', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
cardAttempts: const {},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.cardAttempts, isEmpty);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto', () {
|
||||||
|
test('converts model to DTO correctly', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cardAttempts = {'card1': 2, 'card2': 4};
|
||||||
|
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
learnedCards: 7,
|
||||||
|
studyTimeMinutes: 60,
|
||||||
|
lastStudyDate: now,
|
||||||
|
firstStudyDate: now,
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
averageAccuracy: 0.75,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.packId, 'test_pack');
|
||||||
|
expect(dto.totalCards, 10);
|
||||||
|
expect(dto.learnedCards, 7);
|
||||||
|
expect(dto.studyTimeMinutes, 60);
|
||||||
|
expect(dto.lastStudyDate, now);
|
||||||
|
expect(dto.firstStudyDate, now);
|
||||||
|
expect(dto.cardAttempts, cardAttempts);
|
||||||
|
expect(dto.averageAccuracy, 0.75);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts model with null dates to DTO', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.lastStudyDate, isNull);
|
||||||
|
expect(dto.firstStudyDate, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates new map instance in DTO', () {
|
||||||
|
final cardAttempts = {'card1': 1};
|
||||||
|
final model = PackProgressModel(cardAttempts: cardAttempts);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.cardAttempts, equals(cardAttempts));
|
||||||
|
expect(dto.cardAttempts, isNot(same(cardAttempts)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromDto', () {
|
||||||
|
test('creates model from DTO correctly', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cardAttempts = {'card1': 3, 'card2': 5};
|
||||||
|
|
||||||
|
final dto = PackProgressDto(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 20,
|
||||||
|
learnedCards: 15,
|
||||||
|
studyTimeMinutes: 120,
|
||||||
|
lastStudyDate: now,
|
||||||
|
firstStudyDate: now,
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
averageAccuracy: 0.85,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = PackProgressModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.packId, 'test_pack');
|
||||||
|
expect(model.totalCards, 20);
|
||||||
|
expect(model.learnedCards, 15);
|
||||||
|
expect(model.studyTimeMinutes, 120);
|
||||||
|
expect(model.lastStudyDate, now);
|
||||||
|
expect(model.firstStudyDate, now);
|
||||||
|
expect(model.cardAttempts, cardAttempts);
|
||||||
|
expect(model.averageAccuracy, 0.85);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model from DTO with null dates', () {
|
||||||
|
final dto = PackProgressDto(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = PackProgressModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.lastStudyDate, isNull);
|
||||||
|
expect(model.firstStudyDate, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates new map instance from DTO', () {
|
||||||
|
final cardAttempts = {'card1': 1};
|
||||||
|
final dto = PackProgressDto(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = PackProgressModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(model.cardAttempts, equals(cardAttempts));
|
||||||
|
expect(model.cardAttempts, isNot(same(cardAttempts)));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto and fromDto roundtrip', () {
|
||||||
|
test('preserves all data through conversion', () {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final cardAttempts = {'card1': 2, 'card2': 4, 'card3': 1};
|
||||||
|
|
||||||
|
final original = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 30,
|
||||||
|
learnedCards: 20,
|
||||||
|
studyTimeMinutes: 180,
|
||||||
|
lastStudyDate: now,
|
||||||
|
firstStudyDate: now.subtract(const Duration(days: 5)),
|
||||||
|
cardAttempts: cardAttempts,
|
||||||
|
averageAccuracy: 0.92,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = original.toDto();
|
||||||
|
final restored = PackProgressModel.fromDto(dto);
|
||||||
|
|
||||||
|
expect(restored.packId, original.packId);
|
||||||
|
expect(restored.totalCards, original.totalCards);
|
||||||
|
expect(restored.learnedCards, original.learnedCards);
|
||||||
|
expect(restored.studyTimeMinutes, original.studyTimeMinutes);
|
||||||
|
expect(restored.lastStudyDate, original.lastStudyDate);
|
||||||
|
expect(restored.firstStudyDate, original.firstStudyDate);
|
||||||
|
expect(restored.cardAttempts, original.cardAttempts);
|
||||||
|
expect(restored.averageAccuracy, original.averageAccuracy);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateCardProgress', () {
|
||||||
|
test('updates card attempts correctly', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
cardAttempts: {'card1': 2},
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'card1',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.cardAttempts['card1'], 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds new card to attempts map', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
totalCards: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'new_card',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.cardAttempts['new_card'], 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates study time correctly', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
studyTimeMinutes: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'card1',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.studyTimeMinutes, 15);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets lastStudyDate to current time', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
);
|
||||||
|
|
||||||
|
final beforeUpdate = DateTime.now();
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'card1',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
final afterUpdate = DateTime.now();
|
||||||
|
|
||||||
|
expect(updated.lastStudyDate, isNotNull);
|
||||||
|
expect(
|
||||||
|
updated.lastStudyDate!.isAfter(beforeUpdate.subtract(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
updated.lastStudyDate!.isBefore(afterUpdate.add(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('sets firstStudyDate if null', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'card1',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.firstStudyDate, isNotNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves firstStudyDate if already set', () {
|
||||||
|
final originalDate = DateTime(2024, 1, 1);
|
||||||
|
final model = PackProgressModel(
|
||||||
|
packId: 'test_pack',
|
||||||
|
firstStudyDate: originalDate,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.updateCardProgress(
|
||||||
|
cardId: 'card1',
|
||||||
|
wasCorrect: true,
|
||||||
|
additionalTimeMinutes: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(updated.firstStudyDate, originalDate);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('hasStarted', () {
|
||||||
|
test('returns false for new pack', () {
|
||||||
|
final model = PackProgressModel();
|
||||||
|
|
||||||
|
expect(model.hasStarted, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when learnedCards > 0', () {
|
||||||
|
final model = PackProgressModel(learnedCards: 1);
|
||||||
|
|
||||||
|
expect(model.hasStarted, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when studyTimeMinutes > 0', () {
|
||||||
|
final model = PackProgressModel(studyTimeMinutes: 1);
|
||||||
|
|
||||||
|
expect(model.hasStarted, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns true when both learnedCards and studyTimeMinutes > 0', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
learnedCards: 5,
|
||||||
|
studyTimeMinutes: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.hasStarted, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('progress', () {
|
||||||
|
test('returns 0.0 when totalCards is 0', () {
|
||||||
|
final model = PackProgressModel();
|
||||||
|
|
||||||
|
expect(model.progress, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 0.0 when learnedCards is 0', () {
|
||||||
|
final model = PackProgressModel(totalCards: 10);
|
||||||
|
|
||||||
|
expect(model.progress, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calculates progress correctly', () {
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.progress, 0.33);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 1.0 when all cards learned', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
totalCards: 10,
|
||||||
|
learnedCards: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.progress, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles learnedCards exceeding totalCards', () {
|
||||||
|
final model = PackProgressModel(
|
||||||
|
totalCards: 10,
|
||||||
|
learnedCards: 15,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.progress, 1.5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('edge cases', () {
|
||||||
|
test('handles empty cardAttempts map', () {
|
||||||
|
final model = PackProgressModel(cardAttempts: const {});
|
||||||
|
|
||||||
|
expect(model.cardAttempts, isEmpty);
|
||||||
|
expect(model.toDto().cardAttempts, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles large cardAttempts map', () {
|
||||||
|
final cardAttempts = Map.fromEntries(
|
||||||
|
List.generate(100, (i) => MapEntry('card$i', i)),
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = PackProgressModel(cardAttempts: cardAttempts);
|
||||||
|
|
||||||
|
expect(model.cardAttempts.length, 100);
|
||||||
|
expect(model.toDto().cardAttempts.length, 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero averageAccuracy', () {
|
||||||
|
final model = PackProgressModel(averageAccuracy: 0.0);
|
||||||
|
|
||||||
|
expect(model.averageAccuracy, 0.0);
|
||||||
|
expect(model.toDto().averageAccuracy, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles maximum averageAccuracy', () {
|
||||||
|
final model = PackProgressModel(averageAccuracy: 1.0);
|
||||||
|
|
||||||
|
expect(model.averageAccuracy, 1.0);
|
||||||
|
expect(model.toDto().averageAccuracy, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles very large studyTimeMinutes', () {
|
||||||
|
final model = PackProgressModel(studyTimeMinutes: 999999);
|
||||||
|
|
||||||
|
expect(model.studyTimeMinutes, 999999);
|
||||||
|
expect(model.toDto().studyTimeMinutes, 999999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
640
mnemo_cards_backend/test/models/study_session_model_test.dart
Normal file
640
mnemo_cards_backend/test/models/study_session_model_test.dart
Normal file
|
|
@ -0,0 +1,640 @@
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_common_backend/mnemo_cards_common_backend.dart';
|
||||||
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('StudySessionModel', () {
|
||||||
|
group('constructor', () {
|
||||||
|
test('creates model with required parameters', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.userId, 1);
|
||||||
|
expect(model.startTime, startTime);
|
||||||
|
expect(model.sessionId, isNull);
|
||||||
|
expect(model.endTime, isNull);
|
||||||
|
expect(model.wordsLearned, 0);
|
||||||
|
expect(model.testsCompleted, 0);
|
||||||
|
expect(model.accuracy, 0.0);
|
||||||
|
expect(model.packId, isNull);
|
||||||
|
expect(model.testId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with all parameters', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
sessionId: 'session_123',
|
||||||
|
userId: 42,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
testsCompleted: 2,
|
||||||
|
accuracy: 0.85,
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionId, 'session_123');
|
||||||
|
expect(model.userId, 42);
|
||||||
|
expect(model.startTime, startTime);
|
||||||
|
expect(model.endTime, endTime);
|
||||||
|
expect(model.wordsLearned, 10);
|
||||||
|
expect(model.testsCompleted, 2);
|
||||||
|
expect(model.accuracy, 0.85);
|
||||||
|
expect(model.packId, 'pack_1');
|
||||||
|
expect(model.testId, 'test_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model with default values', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.wordsLearned, 0);
|
||||||
|
expect(model.testsCompleted, 0);
|
||||||
|
expect(model.accuracy, 0.0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto', () {
|
||||||
|
test('converts model to DTO correctly', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
sessionId: 'session_123',
|
||||||
|
userId: 42,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
testsCompleted: 2,
|
||||||
|
accuracy: 0.85,
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.sessionId, 'session_123');
|
||||||
|
expect(dto.startTime, startTime);
|
||||||
|
expect(dto.endTime, endTime);
|
||||||
|
expect(dto.wordsLearned, 10);
|
||||||
|
expect(dto.testsCompleted, 2);
|
||||||
|
expect(dto.accuracy, 0.85);
|
||||||
|
expect(dto.packId, 'pack_1');
|
||||||
|
expect(dto.testId, 'test_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts model with null values to DTO', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = model.toDto();
|
||||||
|
|
||||||
|
expect(dto.sessionId, isNull);
|
||||||
|
expect(dto.endTime, isNull);
|
||||||
|
expect(dto.packId, isNull);
|
||||||
|
expect(dto.testId, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('fromDto', () {
|
||||||
|
test('creates model from DTO correctly', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final dto = StudySessionDto(
|
||||||
|
sessionId: 'session_123',
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
testsCompleted: 2,
|
||||||
|
accuracy: 0.85,
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = StudySessionModel.fromDto(dto, 42);
|
||||||
|
|
||||||
|
expect(model.sessionId, 'session_123');
|
||||||
|
expect(model.userId, 42);
|
||||||
|
expect(model.startTime, startTime);
|
||||||
|
expect(model.endTime, endTime);
|
||||||
|
expect(model.wordsLearned, 10);
|
||||||
|
expect(model.testsCompleted, 2);
|
||||||
|
expect(model.accuracy, 0.85);
|
||||||
|
expect(model.packId, 'pack_1');
|
||||||
|
expect(model.testId, 'test_1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates model from DTO with null values', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final dto = StudySessionDto(
|
||||||
|
startTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
final model = StudySessionModel.fromDto(dto, 1);
|
||||||
|
|
||||||
|
expect(model.sessionId, isNull);
|
||||||
|
expect(model.endTime, isNull);
|
||||||
|
expect(model.packId, isNull);
|
||||||
|
expect(model.testId, isNull);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('toDto and fromDto roundtrip', () {
|
||||||
|
test('preserves all data through conversion', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 45));
|
||||||
|
|
||||||
|
final original = StudySessionModel(
|
||||||
|
sessionId: 'session_456',
|
||||||
|
userId: 100,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 25,
|
||||||
|
testsCompleted: 3,
|
||||||
|
accuracy: 0.92,
|
||||||
|
packId: 'pack_2',
|
||||||
|
testId: 'test_2',
|
||||||
|
);
|
||||||
|
|
||||||
|
final dto = original.toDto();
|
||||||
|
final restored = StudySessionModel.fromDto(dto, original.userId);
|
||||||
|
|
||||||
|
expect(restored.sessionId, original.sessionId);
|
||||||
|
expect(restored.userId, original.userId);
|
||||||
|
expect(restored.startTime, original.startTime);
|
||||||
|
expect(restored.endTime, original.endTime);
|
||||||
|
expect(restored.wordsLearned, original.wordsLearned);
|
||||||
|
expect(restored.testsCompleted, original.testsCompleted);
|
||||||
|
expect(restored.accuracy, original.accuracy);
|
||||||
|
expect(restored.packId, original.packId);
|
||||||
|
expect(restored.testId, original.testId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isActive', () {
|
||||||
|
test('returns true when endTime is null', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isActive, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when endTime is set', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: startTime.add(const Duration(minutes: 30)),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isActive, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('duration', () {
|
||||||
|
test('calculates duration correctly when ended', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.duration, const Duration(minutes: 30));
|
||||||
|
});
|
||||||
|
|
||||||
|
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 duration = model.duration;
|
||||||
|
expect(duration.inMinutes, greaterThanOrEqualTo(14));
|
||||||
|
expect(duration.inMinutes, lessThanOrEqualTo(16));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('durationMinutes', () {
|
||||||
|
test('returns duration in minutes correctly', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 45));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.durationMinutes, 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero duration', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.durationMinutes, 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('sessionType', () {
|
||||||
|
test('returns Test Session when testId is set', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionType, 'Test Session');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns Pack Study when packId is set', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
packId: 'pack_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionType, 'Pack Study');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns General Study when neither is set', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionType, 'General Study');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prioritizes testId over packId', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionType, 'Test Session');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('productivityScore', () {
|
||||||
|
test('calculates productivity score correctly', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 15,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.productivityScore, 0.5); // 15 words / 30 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns 0.0 when duration is 0', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: startTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.productivityScore, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles active session', () {
|
||||||
|
final startTime = DateTime.now().subtract(const Duration(minutes: 10));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
wordsLearned: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final score = model.productivityScore;
|
||||||
|
expect(score, greaterThan(0));
|
||||||
|
expect(score, lessThanOrEqualTo(1.0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('isProductive', () {
|
||||||
|
test('returns true for productive session', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 10));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
accuracy: 0.8,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isProductive, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when wordsLearned < 5', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 10));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 3,
|
||||||
|
accuracy: 0.8,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isProductive, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when accuracy < 0.7', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 10));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
accuracy: 0.6,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isProductive, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns false when duration < 5 minutes', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 3));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
wordsLearned: 10,
|
||||||
|
accuracy: 0.8,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.isProductive, isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('end', () {
|
||||||
|
test('sets endTime to current time', () {
|
||||||
|
final startTime = DateTime.now().subtract(const Duration(minutes: 10));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
final beforeEnd = DateTime.now();
|
||||||
|
final ended = model.end(
|
||||||
|
wordsLearned: 10,
|
||||||
|
testsCompleted: 2,
|
||||||
|
accuracy: 0.85,
|
||||||
|
);
|
||||||
|
final afterEnd = DateTime.now();
|
||||||
|
|
||||||
|
expect(ended.endTime, isNotNull);
|
||||||
|
expect(
|
||||||
|
ended.endTime!.isAfter(beforeEnd.subtract(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
ended.endTime!.isBefore(afterEnd.add(const Duration(seconds: 1))),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates statistics correctly', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final ended = model.end(
|
||||||
|
wordsLearned: 15,
|
||||||
|
testsCompleted: 3,
|
||||||
|
accuracy: 0.9,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(ended.wordsLearned, 15);
|
||||||
|
expect(ended.testsCompleted, 3);
|
||||||
|
expect(ended.accuracy, 0.9);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other fields', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
sessionId: 'session_123',
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final ended = model.end();
|
||||||
|
|
||||||
|
expect(ended.sessionId, model.sessionId);
|
||||||
|
expect(ended.userId, model.userId);
|
||||||
|
expect(ended.startTime, model.startTime);
|
||||||
|
expect(ended.packId, model.packId);
|
||||||
|
expect(ended.testId, model.testId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('addProgress', () {
|
||||||
|
test('adds wordsLearned correctly', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
wordsLearned: 5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(wordsLearned: 3);
|
||||||
|
|
||||||
|
expect(updated.wordsLearned, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds testsCompleted correctly', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
testsCompleted: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(testsCompleted: 2);
|
||||||
|
|
||||||
|
expect(updated.testsCompleted, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates accuracy when provided', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
accuracy: 0.5,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(accuracy: 0.8);
|
||||||
|
|
||||||
|
expect(updated.accuracy, 0.8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves accuracy when not provided', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
accuracy: 0.7,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(wordsLearned: 5);
|
||||||
|
|
||||||
|
expect(updated.accuracy, 0.7);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves endTime', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(minutes: 30));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(wordsLearned: 5);
|
||||||
|
|
||||||
|
expect(updated.endTime, endTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preserves other fields', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
sessionId: 'session_123',
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
packId: 'pack_1',
|
||||||
|
testId: 'test_1',
|
||||||
|
);
|
||||||
|
|
||||||
|
final updated = model.addProgress(wordsLearned: 5);
|
||||||
|
|
||||||
|
expect(updated.sessionId, model.sessionId);
|
||||||
|
expect(updated.userId, model.userId);
|
||||||
|
expect(updated.startTime, model.startTime);
|
||||||
|
expect(updated.packId, model.packId);
|
||||||
|
expect(updated.testId, model.testId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('edge cases', () {
|
||||||
|
test('handles zero wordsLearned', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
wordsLearned: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.wordsLearned, 0);
|
||||||
|
expect(model.toDto().wordsLearned, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero testsCompleted', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
testsCompleted: 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.testsCompleted, 0);
|
||||||
|
expect(model.toDto().testsCompleted, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles zero accuracy', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
accuracy: 0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.accuracy, 0.0);
|
||||||
|
expect(model.toDto().accuracy, 0.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles maximum accuracy', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
accuracy: 1.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.accuracy, 1.0);
|
||||||
|
expect(model.toDto().accuracy, 1.0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles very long session', () {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
final endTime = startTime.add(const Duration(hours: 5));
|
||||||
|
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 1,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.durationMinutes, 300);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles empty sessionId', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
sessionId: '',
|
||||||
|
userId: 1,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.sessionId, '');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles very large userId', () {
|
||||||
|
final model = StudySessionModel(
|
||||||
|
userId: 999999999,
|
||||||
|
startTime: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(model.userId, 999999999);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -75,7 +75,7 @@ class PackProgressModel {
|
||||||
required int additionalTimeMinutes,
|
required int additionalTimeMinutes,
|
||||||
}) {
|
}) {
|
||||||
final currentAttempts = cardAttempts[cardId] ?? 0;
|
final currentAttempts = cardAttempts[cardId] ?? 0;
|
||||||
final newAttempts = {cardId: currentAttempts + 1, ...cardAttempts};
|
final newAttempts = {...cardAttempts, cardId: currentAttempts + 1};
|
||||||
|
|
||||||
// Simple accuracy calculation - could be more sophisticated
|
// Simple accuracy calculation - could be more sophisticated
|
||||||
final totalAttempts = newAttempts.values.fold<int>(0, (sum, attempts) => sum + attempts);
|
final totalAttempts = newAttempts.values.fold<int>(0, (sum, attempts) => sum + attempts);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
{"format-version":[1,0,0],"native-assets":{}}
|
||||||
Binary file not shown.
|
|
@ -49,6 +49,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
double _shuffleAnimationTurns = 0;
|
double _shuffleAnimationTurns = 0;
|
||||||
Map<int, int> _previousCardIndexById = {};
|
Map<int, int> _previousCardIndexById = {};
|
||||||
int _lastAnimatedShuffleKey = 0;
|
int _lastAnimatedShuffleKey = 0;
|
||||||
|
bool _isNavigatingToPurchase = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
|
@ -78,14 +79,6 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
final packResponse = await appScope.httpRepository.getPack(widget.packId);
|
final packResponse = await appScope.httpRepository.getPack(widget.packId);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// Check if pack needs to be purchased
|
|
||||||
if (packResponse.responseType == GetCardPackResponseType.buy) {
|
|
||||||
log('Pack requires purchase, redirecting to purchase page', name: 'PackDetailsPage');
|
|
||||||
// Navigate to purchase page
|
|
||||||
context.replace('/purchase/${widget.packId}');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_packResponse = packResponse;
|
_packResponse = packResponse;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
|
|
@ -222,7 +215,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
return _buildErrorState();
|
return _buildErrorState();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_packResponse == null || _packResponse is! CardPackDto) {
|
if (_packResponse == null) {
|
||||||
return const Center(child: Text('Pack not found'));
|
return const Center(child: Text('Pack not found'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,10 +253,109 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Checks if the pack requires purchase
|
||||||
|
bool _requiresPurchase() {
|
||||||
|
return _packResponse?.responseType == GetCardPackResponseType.buy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a CardPackDto from CardPackBuyDto for display purposes
|
||||||
|
CardPackDto _createDisplayPackFromBuyDto(CardPackBuyDto buyDto) {
|
||||||
|
return CardPackDto(
|
||||||
|
id: buyDto.id,
|
||||||
|
title: buyDto.title,
|
||||||
|
subtitle: buyDto.subtitle,
|
||||||
|
color: buyDto.color,
|
||||||
|
version: buyDto.version,
|
||||||
|
cards: buyDto.cards,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the Buy Pack button
|
||||||
|
Widget _buildBuyPackButton(Color packColor) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _isNavigatingToPurchase ? null : _navigateToPurchase,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: packColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size.fromHeight(56),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _isNavigatingToPurchase
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.shopping_cart, size: 20),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'Buy Pack',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to the purchase page
|
||||||
|
Future<void> _navigateToPurchase() async {
|
||||||
|
if (_isNavigatingToPurchase) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isNavigatingToPurchase = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
if (mounted) {
|
||||||
|
await context.push('/purchase/${widget.packId}');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isNavigatingToPurchase = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildPackDetails() {
|
Widget _buildPackDetails() {
|
||||||
final pack = _packResponse! as CardPackDto;
|
// Handle both CardPackDto and CardPackBuyDto
|
||||||
|
final pack = _packResponse;
|
||||||
|
if (pack == null) {
|
||||||
|
return const Center(child: Text('Pack not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if pack requires purchase
|
||||||
|
final requiresPurchase = _requiresPurchase();
|
||||||
|
|
||||||
|
// Get pack data - CardPackDto or CardPackBuyDto both have similar structure
|
||||||
|
CardPackDto? packDto;
|
||||||
|
CardPackBuyDto? buyDto;
|
||||||
|
if (pack is CardPackDto) {
|
||||||
|
packDto = pack;
|
||||||
|
} else if (pack is CardPackBuyDto) {
|
||||||
|
buyDto = pack;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use packDto if available, otherwise use buyDto
|
||||||
|
final displayPack = packDto ?? _createDisplayPackFromBuyDto(buyDto!);
|
||||||
final cards = _getDisplayCards();
|
final cards = _getDisplayCards();
|
||||||
final packColor = pack.color?.asColor ?? AppColors.borderGray;
|
final packColor = displayPack.color?.asColor ?? AppColors.borderGray;
|
||||||
|
|
||||||
// Определяем, нужно ли показывать боковую панель
|
// Определяем, нужно ли показывать боковую панель
|
||||||
final showSidebar =
|
final showSidebar =
|
||||||
|
|
@ -273,34 +365,41 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
children: [
|
children: [
|
||||||
// Кастомный заголовок
|
// Кастомный заголовок
|
||||||
PackDetailsHeader(
|
PackDetailsHeader(
|
||||||
pack: pack,
|
pack: displayPack,
|
||||||
progress: _packProgress,
|
progress: _packProgress,
|
||||||
totalCards: pack.cards.length,
|
totalCards: displayPack.cards.length,
|
||||||
backButtonText: 'к темам',
|
backButtonText: 'к темам',
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Панель управления
|
// Buy Pack button if pack requires purchase
|
||||||
PackDetailsControls(
|
if (requiresPurchase) ...[
|
||||||
isGridView: _isGridView,
|
_buildBuyPackButton(packColor),
|
||||||
onToggleView: () {
|
const SizedBox(height: 16),
|
||||||
setState(() {
|
],
|
||||||
_isGridView = !_isGridView;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onShuffle: () => _shuffleCards(),
|
|
||||||
onToggleFavorites: () {
|
|
||||||
setState(() {
|
|
||||||
_isFavoritesMode = !_isFavoritesMode;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
isFavoritesMode: _isFavoritesMode,
|
|
||||||
isShuffleActive: _isShuffled,
|
|
||||||
shuffleTurns: _shuffleAnimationTurns,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
// Панель управления (only show if pack is purchased)
|
||||||
|
if (!requiresPurchase) ...[
|
||||||
|
PackDetailsControls(
|
||||||
|
isGridView: _isGridView,
|
||||||
|
onToggleView: () {
|
||||||
|
setState(() {
|
||||||
|
_isGridView = !_isGridView;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onShuffle: () => _shuffleCards(),
|
||||||
|
onToggleFavorites: () {
|
||||||
|
setState(() {
|
||||||
|
_isFavoritesMode = !_isFavoritesMode;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
isFavoritesMode: _isFavoritesMode,
|
||||||
|
isShuffleActive: _isShuffled,
|
||||||
|
shuffleTurns: _shuffleAnimationTurns,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
|
||||||
// Основное содержимое
|
// Основное содержимое
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -854,7 +953,16 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
|
|
||||||
/// Shuffles the cards
|
/// Shuffles the cards
|
||||||
void _shuffleCards() {
|
void _shuffleCards() {
|
||||||
if (_packResponse == null || _packResponse is! CardPackDto) return;
|
if (_packResponse == null) return;
|
||||||
|
|
||||||
|
List<GameCardDto> cardsToShuffle;
|
||||||
|
if (_packResponse is CardPackDto) {
|
||||||
|
cardsToShuffle = (_packResponse as CardPackDto).cards;
|
||||||
|
} else if (_packResponse is CardPackBuyDto) {
|
||||||
|
cardsToShuffle = (_packResponse as CardPackBuyDto).cards;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_shuffleAnimationKey++;
|
_shuffleAnimationKey++;
|
||||||
|
|
@ -862,7 +970,7 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
_isShuffled = !_isShuffled;
|
_isShuffled = !_isShuffled;
|
||||||
if (_isShuffled) {
|
if (_isShuffled) {
|
||||||
// Create a shuffled copy of the cards
|
// Create a shuffled copy of the cards
|
||||||
_shuffledCards = List<GameCardDto>.from((_packResponse as CardPackDto).cards)..shuffle();
|
_shuffledCards = List<GameCardDto>.from(cardsToShuffle)..shuffle();
|
||||||
log('Cards shuffled', name: 'PackDetailsPage');
|
log('Cards shuffled', name: 'PackDetailsPage');
|
||||||
} else {
|
} else {
|
||||||
// Reset to original order
|
// Reset to original order
|
||||||
|
|
@ -874,13 +982,27 @@ class _PackDetailsPageState extends State<PackDetailsPage> {
|
||||||
|
|
||||||
/// Gets the cards to display (shuffled or original)
|
/// Gets the cards to display (shuffled or original)
|
||||||
List<GameCardDto> _getDisplayCards() {
|
List<GameCardDto> _getDisplayCards() {
|
||||||
if (_packResponse == null || _packResponse is! CardPackDto) return [];
|
if (_packResponse == null) return [];
|
||||||
|
|
||||||
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
// Handle CardPackDto
|
||||||
return _shuffledCards;
|
if (_packResponse is CardPackDto) {
|
||||||
|
final packDto = _packResponse as CardPackDto;
|
||||||
|
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
||||||
|
return _shuffledCards;
|
||||||
|
}
|
||||||
|
return packDto.cards;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (_packResponse as CardPackDto).cards;
|
// Handle CardPackBuyDto
|
||||||
|
if (_packResponse is CardPackBuyDto) {
|
||||||
|
final buyDto = _packResponse as CardPackBuyDto;
|
||||||
|
if (_isShuffled && _shuffledCards.isNotEmpty) {
|
||||||
|
return _shuffledCards;
|
||||||
|
}
|
||||||
|
return buyDto.cards;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Проверяет, является ли карточка избранной
|
/// Проверяет, является ли карточка избранной
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import 'dart:developer';
|
import 'dart:developer';
|
||||||
|
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../../utils/adsgram_stub.dart';
|
import '../../utils/adsgram_stub.dart';
|
||||||
|
|
@ -89,13 +90,14 @@ class _AdsRewardButtonState extends State<AdsRewardButton> {
|
||||||
|
|
||||||
Future<void> _showAdsgramRewardedAd(AdsRewardOffer offer) async {
|
Future<void> _showAdsgramRewardedAd(AdsRewardOffer offer) async {
|
||||||
try {
|
try {
|
||||||
// Get current user ID for reward callback
|
// Get current user ID for reward callback and analytics
|
||||||
final appScope = ScopeProvider.of<AppScopeContainer>(
|
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
);
|
);
|
||||||
final userScope = appScope?.userScopeHolder.scope;
|
final userScope = appScope?.userScopeHolder.scope;
|
||||||
final currentUser = userScope?.userStateManager.user;
|
final currentUser = userScope?.userStateManager.user;
|
||||||
|
final analytics = appScope?.analytics;
|
||||||
|
|
||||||
if (currentUser == null) {
|
if (currentUser == null) {
|
||||||
log('No authenticated user found', name: 'AdsRewardButton');
|
log('No authenticated user found', name: 'AdsRewardButton');
|
||||||
|
|
@ -107,7 +109,14 @@ class _AdsRewardButtonState extends State<AdsRewardButton> {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always use real Adsgram SDK (no simulation)
|
// Emit analytics event for ad impression
|
||||||
|
await analytics?.logEvent(
|
||||||
|
name: 'ads_reward_impression',
|
||||||
|
parameters: {
|
||||||
|
'pack_id': offer.packId,
|
||||||
|
'product_type': offer.product.type.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Configure Adsgram ad parameters
|
// Configure Adsgram ad parameters
|
||||||
final adConfig = AdsgramAd(
|
final adConfig = AdsgramAd(
|
||||||
|
|
@ -115,12 +124,33 @@ class _AdsRewardButtonState extends State<AdsRewardButton> {
|
||||||
rewardAmount: ApiConfigV2.adsgramRewardAmount,
|
rewardAmount: ApiConfigV2.adsgramRewardAmount,
|
||||||
onReward: () async {
|
onReward: () async {
|
||||||
log('Ad completed successfully, claiming reward', name: 'AdsRewardButton');
|
log('Ad completed successfully, claiming reward', name: 'AdsRewardButton');
|
||||||
|
|
||||||
|
// Emit analytics event for ad completion
|
||||||
|
await analytics?.logEvent(
|
||||||
|
name: 'ads_reward_completed',
|
||||||
|
parameters: {
|
||||||
|
'pack_id': offer.packId,
|
||||||
|
'product_type': offer.product.type.name,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
await _callRewardCallback(currentUser.id.toString());
|
await _callRewardCallback(currentUser.id.toString());
|
||||||
await _adsRewardStateManager?.claimReward();
|
await _adsRewardStateManager?.claimReward();
|
||||||
widget.onSuccess?.call();
|
widget.onSuccess?.call();
|
||||||
},
|
},
|
||||||
onError: (error) {
|
onError: (error) {
|
||||||
log('Ad failed: $error', name: 'AdsRewardButton');
|
log('Ad failed: $error', name: 'AdsRewardButton');
|
||||||
|
|
||||||
|
// Emit analytics event for ad failure
|
||||||
|
analytics?.logEvent(
|
||||||
|
name: 'ads_reward_failed',
|
||||||
|
parameters: {
|
||||||
|
'pack_id': offer.packId,
|
||||||
|
'product_type': offer.product.type.name,
|
||||||
|
'error': error.length > 100 ? error.substring(0, 100) : error,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Show error to user
|
// Show error to user
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
|
@ -135,6 +165,23 @@ class _AdsRewardButtonState extends State<AdsRewardButton> {
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
log('Failed to show Adsgram ad', error: e, stackTrace: s, name: 'AdsRewardButton');
|
log('Failed to show Adsgram ad', error: e, stackTrace: s, name: 'AdsRewardButton');
|
||||||
|
|
||||||
|
// Emit analytics event for ad show failure
|
||||||
|
final appScope = ScopeProvider.of<AppScopeContainer>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
final analytics = appScope?.analytics;
|
||||||
|
await analytics?.logEvent(
|
||||||
|
name: 'ads_reward_show_failed',
|
||||||
|
parameters: {
|
||||||
|
'pack_id': offer.packId,
|
||||||
|
'product_type': offer.product.type.name,
|
||||||
|
'error': e.toString().length > 100
|
||||||
|
? e.toString().substring(0, 100)
|
||||||
|
: e.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(content: Text('Failed to load ad: ${e.toString()}')),
|
SnackBar(content: Text('Failed to load ad: ${e.toString()}')),
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,27 @@
|
||||||
/// Real Adsgram SDK interface using JavaScript interop
|
/// Real Adsgram SDK interface using JavaScript interop
|
||||||
/// This provides a Dart interface to the Adsgram JavaScript SDK
|
///
|
||||||
|
/// This provides a Dart interface to the Adsgram JavaScript SDK.
|
||||||
|
/// The SDK is loaded via script tag in web/index.html:
|
||||||
|
/// <script src="https://sad.adsgram.ai/js/sad.min.js"></script>
|
||||||
|
///
|
||||||
|
/// The JavaScript bridge functions are defined in web/foos.js
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:developer' as developer;
|
||||||
import 'dart:js_util' as js_util;
|
import 'dart:js_util' as js_util;
|
||||||
|
|
||||||
/// Configuration for Adsgram ad display
|
/// Configuration for Adsgram ad display
|
||||||
class AdsgramAd {
|
class AdsgramAd {
|
||||||
|
/// Adsgram block ID (required)
|
||||||
final String blockId;
|
final String blockId;
|
||||||
|
|
||||||
|
/// Reward amount (for tracking purposes)
|
||||||
final int rewardAmount;
|
final int rewardAmount;
|
||||||
|
|
||||||
|
/// Callback invoked when ad completes successfully
|
||||||
final void Function()? onReward;
|
final void Function()? onReward;
|
||||||
|
|
||||||
|
/// Callback invoked when ad fails or is closed early
|
||||||
final void Function(String error)? onError;
|
final void Function(String error)? onError;
|
||||||
|
|
||||||
const AdsgramAd({
|
const AdsgramAd({
|
||||||
|
|
@ -25,63 +39,142 @@ class Adsgram {
|
||||||
|
|
||||||
Adsgram._internal();
|
Adsgram._internal();
|
||||||
|
|
||||||
/// Show a rewarded ad using the Adsgram JavaScript SDK
|
/// Check if Adsgram SDK is available in the JavaScript environment
|
||||||
/// This calls the JavaScript showAd() function defined in web/foos.js
|
bool get isAvailable {
|
||||||
Future<void> showRewardedAd(AdsgramAd adConfig) async {
|
|
||||||
try {
|
try {
|
||||||
|
final adsgram = js_util.getProperty<dynamic>(js_util.globalThis, 'Adsgram');
|
||||||
|
return adsgram != null;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Show a rewarded ad using the Adsgram JavaScript SDK
|
||||||
|
///
|
||||||
|
/// This method:
|
||||||
|
/// 1. Sets up JavaScript callbacks for reward/error events
|
||||||
|
/// 2. Calls the JavaScript showAdWithBlockId() function
|
||||||
|
/// 3. Handles ad lifecycle events via callbacks
|
||||||
|
///
|
||||||
|
/// Throws [Exception] if SDK is not available or ad fails to show
|
||||||
|
Future<void> showRewardedAd(AdsgramAd adConfig) async {
|
||||||
|
if (!isAvailable) {
|
||||||
|
final error = 'Adsgram SDK not available. Make sure sad.min.js is loaded.';
|
||||||
|
developer.log(error, name: 'Adsgram');
|
||||||
|
adConfig.onError?.call(error);
|
||||||
|
throw Exception(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adConfig.blockId.isEmpty) {
|
||||||
|
final error = 'Block ID is required';
|
||||||
|
developer.log(error, name: 'Adsgram');
|
||||||
|
adConfig.onError?.call(error);
|
||||||
|
throw ArgumentError(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
developer.log(
|
||||||
|
'Showing rewarded ad with block ID: ${adConfig.blockId}',
|
||||||
|
name: 'Adsgram',
|
||||||
|
);
|
||||||
|
|
||||||
// Set up callbacks in JavaScript before showing ad
|
// Set up callbacks in JavaScript before showing ad
|
||||||
await _setupCallbacks(adConfig);
|
await _setupCallbacks(adConfig);
|
||||||
|
|
||||||
// Call the JavaScript showAd function
|
// Call the JavaScript showAdWithBlockId function
|
||||||
// The function is defined in web/foos.js and handles the Adsgram SDK
|
// The function is defined in web/foos.js and handles the Adsgram SDK
|
||||||
await js_util.callMethod<Future<void>>(
|
await js_util.promiseToFuture<void>(
|
||||||
js_util.globalThis,
|
js_util.callMethod(
|
||||||
'showAd',
|
js_util.globalThis,
|
||||||
<Object?>[],
|
'showAdWithBlockId',
|
||||||
|
<Object?>[adConfig.blockId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Note: The actual reward/error dispatch logic is handled in the JavaScript
|
developer.log('Ad show request completed', name: 'Adsgram');
|
||||||
// The callbacks are triggered from JavaScript when ad completes or fails
|
// Note: The actual reward/error callbacks are triggered from JavaScript
|
||||||
|
// when ad completes or fails
|
||||||
} catch (e) {
|
} catch (e, s) {
|
||||||
|
developer.log(
|
||||||
|
'Failed to show ad',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
name: 'Adsgram',
|
||||||
|
);
|
||||||
// If JavaScript call fails, call error callback
|
// If JavaScript call fails, call error callback
|
||||||
adConfig.onError?.call('Failed to show ad: $e');
|
final errorMessage = 'Failed to show ad: $e';
|
||||||
|
adConfig.onError?.call(errorMessage);
|
||||||
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set up JavaScript callbacks for ad completion
|
/// Set up JavaScript callbacks for ad completion
|
||||||
|
///
|
||||||
|
/// Creates JavaScript wrapper functions that call the Dart callbacks
|
||||||
|
/// These are registered with the JavaScript bridge in foos.js
|
||||||
Future<void> _setupCallbacks(AdsgramAd adConfig) async {
|
Future<void> _setupCallbacks(AdsgramAd adConfig) async {
|
||||||
// Create JavaScript functions that will call the Dart callbacks
|
try {
|
||||||
final rewardJsFunction = js_util.jsify(() {
|
// Create JavaScript function for reward callback
|
||||||
adConfig.onReward?.call();
|
final rewardJsFunction = js_util.allowInterop(() {
|
||||||
});
|
developer.log('Ad reward callback triggered', name: 'Adsgram');
|
||||||
|
adConfig.onReward?.call();
|
||||||
|
});
|
||||||
|
|
||||||
final errorJsFunction = js_util.jsify((String error) {
|
// Create JavaScript function for error callback
|
||||||
adConfig.onError?.call(error);
|
final errorJsFunction = js_util.allowInterop((dynamic error) {
|
||||||
});
|
final errorMessage = error?.toString() ?? 'Unknown error';
|
||||||
|
developer.log(
|
||||||
|
'Ad error callback triggered: $errorMessage',
|
||||||
|
name: 'Adsgram',
|
||||||
|
);
|
||||||
|
adConfig.onError?.call(errorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
// Set the callbacks in JavaScript
|
// Register callbacks with JavaScript bridge
|
||||||
await js_util.callMethod<Future<void>>(
|
js_util.callMethod<void>(
|
||||||
js_util.globalThis,
|
js_util.globalThis,
|
||||||
'setRewardCallback',
|
'setRewardCallback',
|
||||||
<Object?>[rewardJsFunction],
|
<Object?>[rewardJsFunction],
|
||||||
);
|
);
|
||||||
|
|
||||||
await js_util.callMethod<Future<void>>(
|
js_util.callMethod<void>(
|
||||||
js_util.globalThis,
|
js_util.globalThis,
|
||||||
'setErrorCallback',
|
'setErrorCallback',
|
||||||
<Object?>[errorJsFunction],
|
<Object?>[errorJsFunction],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
developer.log('Callbacks registered with JavaScript bridge', name: 'Adsgram');
|
||||||
|
} catch (e, s) {
|
||||||
|
developer.log(
|
||||||
|
'Failed to set up callbacks',
|
||||||
|
error: e,
|
||||||
|
stackTrace: s,
|
||||||
|
name: 'Adsgram',
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Alternative method to show ad with specific block ID
|
/// Alternative method to show ad with specific block ID
|
||||||
/// This allows dynamic block ID configuration
|
///
|
||||||
|
/// This allows dynamic block ID configuration without creating
|
||||||
|
/// an AdsgramAd object. Prefer using [showRewardedAd] for better
|
||||||
|
/// error handling and callback management.
|
||||||
|
///
|
||||||
|
/// Throws [Exception] if ad fails to show
|
||||||
|
@Deprecated('Use showRewardedAd() instead for better error handling')
|
||||||
Future<void> showAdWithBlockId(String blockId) async {
|
Future<void> showAdWithBlockId(String blockId) async {
|
||||||
|
if (!isAvailable) {
|
||||||
|
throw Exception('Adsgram SDK not available');
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await js_util.callMethod<Future<void>>(
|
await js_util.promiseToFuture<void>(
|
||||||
js_util.globalThis,
|
js_util.callMethod(
|
||||||
'showAdWithBlockId',
|
js_util.globalThis,
|
||||||
<Object?>[blockId],
|
'showAdWithBlockId',
|
||||||
|
<Object?>[blockId],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Failed to show ad with block ID $blockId: $e');
|
throw Exception('Failed to show ad with block ID $blockId: $e');
|
||||||
|
|
|
||||||
|
|
@ -102,9 +102,9 @@ dev_dependencies:
|
||||||
flutter:
|
flutter:
|
||||||
uses-material-design: true
|
uses-material-design: true
|
||||||
|
|
||||||
assets:
|
# assets:
|
||||||
- assets/images/
|
# - assets/images/
|
||||||
- assets/icons/
|
# - assets/icons/
|
||||||
|
|
||||||
fonts:
|
fonts:
|
||||||
- family: Nunito
|
- family: Nunito
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mocktail/mocktail.dart';
|
||||||
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/di/app_scope/app_scope_container.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/domain/services/http_repository_v2.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/presentation/pages/pack_details/pack_details_page.dart';
|
||||||
|
import 'package:yx_scope_flutter/yx_scope_flutter.dart';
|
||||||
|
|
||||||
|
// Mock classes
|
||||||
|
class MockAppScopeContainer extends Mock implements AppScopeContainer {}
|
||||||
|
|
||||||
|
class MockHttpRepositoryV2 extends Mock implements HttpRepositoryV2 {}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late MockAppScopeContainer mockAppScope;
|
||||||
|
late MockHttpRepositoryV2 mockHttpRepository;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
mockAppScope = MockAppScopeContainer();
|
||||||
|
mockHttpRepository = MockHttpRepositoryV2();
|
||||||
|
when(() => mockAppScope.httpRepository).thenReturn(mockHttpRepository);
|
||||||
|
});
|
||||||
|
|
||||||
|
group('PackDetailsPage - Buy Pack Button', () {
|
||||||
|
testWidgets(
|
||||||
|
'should display Buy Pack button when pack requires purchase',
|
||||||
|
(tester) async {
|
||||||
|
const packId = 'test-pack-1';
|
||||||
|
final buyDto = CardPackBuyDto(
|
||||||
|
id: packId,
|
||||||
|
title: 'Test Pack',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
cards: [
|
||||||
|
GameCardDto(
|
||||||
|
id: 1,
|
||||||
|
original: 'test',
|
||||||
|
translation: 'тест',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
price: '99₽',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockHttpRepository.getPack(packId))
|
||||||
|
.thenAnswer((_) async => buyDto);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/pack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PackDetailsPage(packId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/purchase/:packId',
|
||||||
|
builder: (context, state) {
|
||||||
|
final packId = state.pathParameters['packId']!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text('Purchase $packId')),
|
||||||
|
body: const Center(child: Text('Purchase Page')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
scope: mockAppScope,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for pack to load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify Buy Pack button is displayed
|
||||||
|
expect(find.text('Buy Pack'), findsOneWidget);
|
||||||
|
expect(find.byIcon(Icons.shopping_cart), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should navigate to PurchasePage with correct packId when Buy Pack button is tapped',
|
||||||
|
(tester) async {
|
||||||
|
const packId = 'test-pack-2';
|
||||||
|
final buyDto = CardPackBuyDto(
|
||||||
|
id: packId,
|
||||||
|
title: 'Test Pack',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
cards: [
|
||||||
|
GameCardDto(
|
||||||
|
id: 1,
|
||||||
|
original: 'test',
|
||||||
|
translation: 'тест',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
price: '99₽',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockHttpRepository.getPack(packId))
|
||||||
|
.thenAnswer((_) async => buyDto);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/pack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PackDetailsPage(packId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/purchase/:packId',
|
||||||
|
builder: (context, state) {
|
||||||
|
final packId = state.pathParameters['packId']!;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: Text('Purchase $packId')),
|
||||||
|
body: Center(child: Text('Purchase Page for $packId')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
scope: mockAppScope,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for pack to load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Buy Pack button
|
||||||
|
await tester.tap(find.text('Buy Pack'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify navigation to PurchasePage
|
||||||
|
expect(find.text('Purchase Page for $packId'), findsOneWidget);
|
||||||
|
expect(find.text('Purchase $packId'), findsOneWidget);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should show loading indicator when navigating to purchase page',
|
||||||
|
(tester) async {
|
||||||
|
const packId = 'test-pack-3';
|
||||||
|
final buyDto = CardPackBuyDto(
|
||||||
|
id: packId,
|
||||||
|
title: 'Test Pack',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
cards: [
|
||||||
|
GameCardDto(
|
||||||
|
id: 1,
|
||||||
|
original: 'test',
|
||||||
|
translation: 'тест',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
price: '99₽',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockHttpRepository.getPack(packId))
|
||||||
|
.thenAnswer((_) async => buyDto);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/pack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PackDetailsPage(packId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/purchase/:packId',
|
||||||
|
builder: (context, state) {
|
||||||
|
return const Scaffold(
|
||||||
|
body: Center(child: Text('Purchase Page')),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
scope: mockAppScope,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for pack to load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Tap Buy Pack button
|
||||||
|
await tester.tap(find.text('Buy Pack'));
|
||||||
|
|
||||||
|
// Pump once to trigger navigation state
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify loading indicator appears (CircularProgressIndicator)
|
||||||
|
expect(find.byType(CircularProgressIndicator), findsWidgets);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should not display Buy Pack button when pack is already purchased',
|
||||||
|
(tester) async {
|
||||||
|
const packId = 'test-pack-4';
|
||||||
|
final packDto = CardPackDto(
|
||||||
|
id: packId,
|
||||||
|
title: 'Test Pack',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
cards: [
|
||||||
|
GameCardDto(
|
||||||
|
id: 1,
|
||||||
|
original: 'test',
|
||||||
|
translation: 'тест',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockHttpRepository.getPack(packId))
|
||||||
|
.thenAnswer((_) async => packDto);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/pack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PackDetailsPage(packId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
scope: mockAppScope,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for pack to load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Verify Buy Pack button is NOT displayed
|
||||||
|
expect(find.text('Buy Pack'), findsNothing);
|
||||||
|
expect(find.byIcon(Icons.shopping_cart), findsNothing);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
'should style Buy Pack button with pack color',
|
||||||
|
(tester) async {
|
||||||
|
const packId = 'test-pack-5';
|
||||||
|
final buyDto = CardPackBuyDto(
|
||||||
|
id: packId,
|
||||||
|
title: 'Test Pack',
|
||||||
|
subtitle: 'Test Subtitle',
|
||||||
|
color: '#FF5733', // Red color
|
||||||
|
cards: [
|
||||||
|
GameCardDto(
|
||||||
|
id: 1,
|
||||||
|
original: 'test',
|
||||||
|
translation: 'тест',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
price: '99₽',
|
||||||
|
);
|
||||||
|
|
||||||
|
when(() => mockHttpRepository.getPack(packId))
|
||||||
|
.thenAnswer((_) async => buyDto);
|
||||||
|
|
||||||
|
final router = GoRouter(
|
||||||
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/pack/:id',
|
||||||
|
builder: (context, state) {
|
||||||
|
final id = state.pathParameters['id']!;
|
||||||
|
return PackDetailsPage(packId: id);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await tester.pumpWidget(
|
||||||
|
ScopeProvider<AppScopeContainer>(
|
||||||
|
scope: mockAppScope,
|
||||||
|
child: MaterialApp.router(
|
||||||
|
routerConfig: router,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Wait for pack to load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Find the FilledButton
|
||||||
|
final button = tester.widget<FilledButton>(
|
||||||
|
find.byType(FilledButton),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify button styling
|
||||||
|
expect(button.style, isNotNull);
|
||||||
|
expect(button.style?.backgroundColor, isNotNull);
|
||||||
|
expect(button.style?.minimumSize, const Size.fromHeight(56));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import 'package:firebase_analytics/firebase_analytics.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
import 'package:mnemo_cards_common/mnemo_cards_common.dart';
|
||||||
|
|
@ -15,15 +16,31 @@ class _MockPacksStateManager extends Mock implements PacksStateManager {}
|
||||||
|
|
||||||
class _MockPackManager extends Mock implements PackManager {}
|
class _MockPackManager extends Mock implements PackManager {}
|
||||||
|
|
||||||
|
class _MockFirebaseAnalytics extends Mock implements FirebaseAnalytics {}
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late _MockAdsRewardService mockAdsRewardService;
|
late _MockAdsRewardService mockAdsRewardService;
|
||||||
late _MockPacksStateManager mockPacksStateManager;
|
late _MockPacksStateManager mockPacksStateManager;
|
||||||
late _MockPackManager mockPackManager;
|
late _MockPackManager mockPackManager;
|
||||||
|
late _MockFirebaseAnalytics mockAnalytics;
|
||||||
|
|
||||||
setUp(() {
|
setUp(() {
|
||||||
mockAdsRewardService = _MockAdsRewardService();
|
mockAdsRewardService = _MockAdsRewardService();
|
||||||
mockPacksStateManager = _MockPacksStateManager();
|
mockPacksStateManager = _MockPacksStateManager();
|
||||||
mockPackManager = _MockPackManager();
|
mockPackManager = _MockPackManager();
|
||||||
|
mockAnalytics = _MockFirebaseAnalytics();
|
||||||
|
|
||||||
|
// Setup default analytics mock behavior
|
||||||
|
when(
|
||||||
|
() => mockAnalytics.logEvent(
|
||||||
|
name: any(named: 'name'),
|
||||||
|
parameters: any(named: 'parameters'),
|
||||||
|
),
|
||||||
|
).thenAnswer((_) async {});
|
||||||
|
});
|
||||||
|
|
||||||
|
setUpAll(() {
|
||||||
|
registerFallbackValue(<String, Object?>{});
|
||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _pumpAdsRewardButton(
|
Future<void> _pumpAdsRewardButton(
|
||||||
|
|
@ -67,6 +84,7 @@ void main() {
|
||||||
|
|
||||||
// TODO: Add proper mocking for the state manager integration
|
// TODO: Add proper mocking for the state manager integration
|
||||||
// This would require more complex setup with dependency injection
|
// This would require more complex setup with dependency injection
|
||||||
|
// including AppScopeContainer, UserScopeContainer, and all dependencies
|
||||||
|
|
||||||
await _pumpAdsRewardButton(tester, packId: 'test-pack');
|
await _pumpAdsRewardButton(tester, packId: 'test-pack');
|
||||||
|
|
||||||
|
|
@ -104,6 +122,47 @@ void main() {
|
||||||
// The button should be disabled while loading
|
// The button should be disabled while loading
|
||||||
expect(widget.onTap, isNull);
|
expect(widget.onTap, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('displays error state with retry option', (tester) async {
|
||||||
|
await _pumpAdsRewardButton(tester, packId: 'test-pack');
|
||||||
|
|
||||||
|
// Wait for initial load
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// The widget should handle error states gracefully
|
||||||
|
// Full error state testing requires mocking the state manager
|
||||||
|
expect(find.byType(GestureDetector), findsWidgets);
|
||||||
|
});
|
||||||
|
|
||||||
|
testWidgets('shows correct button text for different states', (tester) async {
|
||||||
|
await _pumpAdsRewardButton(tester, packId: 'test-pack');
|
||||||
|
|
||||||
|
// Initially should show loading
|
||||||
|
expect(find.text('Loading...'), findsOneWidget);
|
||||||
|
|
||||||
|
// After loading, button text depends on state manager state
|
||||||
|
// Full testing requires proper DI setup
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('AdsRewardButton Analytics', () {
|
||||||
|
// Note: Analytics event testing requires full integration test setup
|
||||||
|
// with AppScopeContainer and UserScopeContainer properly initialized
|
||||||
|
// These tests verify the structure but full integration tests are needed
|
||||||
|
// to verify analytics events are actually emitted
|
||||||
|
|
||||||
|
test('analytics events are defined correctly', () {
|
||||||
|
// Verify that analytics event names match expected values
|
||||||
|
const expectedEvents = [
|
||||||
|
'ads_reward_impression',
|
||||||
|
'ads_reward_completed',
|
||||||
|
'ads_reward_failed',
|
||||||
|
'ads_reward_show_failed',
|
||||||
|
];
|
||||||
|
|
||||||
|
// These event names should match what's used in ads_reward_button.dart
|
||||||
|
expect(expectedEvents, isNotEmpty);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
125
mnemo_cards_web_v2/test/utils/adsgram_stub_test.dart
Normal file
125
mnemo_cards_web_v2/test/utils/adsgram_stub_test.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:mnemo_cards_web_v2/utils/adsgram_stub.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('AdsgramAd', () {
|
||||||
|
test('creates ad config with required parameters', () {
|
||||||
|
const adConfig = AdsgramAd(
|
||||||
|
blockId: '12345',
|
||||||
|
rewardAmount: 1,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adConfig.blockId, equals('12345'));
|
||||||
|
expect(adConfig.rewardAmount, equals(1));
|
||||||
|
expect(adConfig.onReward, isNull);
|
||||||
|
expect(adConfig.onError, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates ad config with callbacks', () {
|
||||||
|
bool rewardCalled = false;
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
|
final adConfig = AdsgramAd(
|
||||||
|
blockId: '12345',
|
||||||
|
rewardAmount: 1,
|
||||||
|
onReward: () {
|
||||||
|
rewardCalled = true;
|
||||||
|
},
|
||||||
|
onError: (error) {
|
||||||
|
errorMessage = error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(adConfig.blockId, equals('12345'));
|
||||||
|
expect(adConfig.onReward, isNotNull);
|
||||||
|
expect(adConfig.onError, isNotNull);
|
||||||
|
|
||||||
|
adConfig.onReward?.call();
|
||||||
|
expect(rewardCalled, isTrue);
|
||||||
|
|
||||||
|
adConfig.onError?.call('test error');
|
||||||
|
expect(errorMessage, equals('test error'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('Adsgram', () {
|
||||||
|
test('isAvailable returns false when SDK not loaded', () {
|
||||||
|
final adsgram = Adsgram.instance;
|
||||||
|
// In test environment, JavaScript interop won't have Adsgram SDK
|
||||||
|
// So isAvailable should return false
|
||||||
|
expect(adsgram.isAvailable, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showRewardedAd throws when SDK not available', () async {
|
||||||
|
final adsgram = Adsgram.instance;
|
||||||
|
bool errorCallbackCalled = false;
|
||||||
|
String? errorMessage;
|
||||||
|
|
||||||
|
const adConfig = AdsgramAd(
|
||||||
|
blockId: '12345',
|
||||||
|
rewardAmount: 1,
|
||||||
|
onError: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
final adConfigWithCallback = AdsgramAd(
|
||||||
|
blockId: '12345',
|
||||||
|
rewardAmount: 1,
|
||||||
|
onError: (error) {
|
||||||
|
errorCallbackCalled = true;
|
||||||
|
errorMessage = error;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test without callback
|
||||||
|
expect(
|
||||||
|
() => adsgram.showRewardedAd(adConfig),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test with callback
|
||||||
|
expect(
|
||||||
|
() => adsgram.showRewardedAd(adConfigWithCallback),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Note: In actual test environment, callbacks may not be called
|
||||||
|
// because JavaScript interop is not available
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showRewardedAd throws ArgumentError when blockId is empty', () async {
|
||||||
|
final adsgram = Adsgram.instance;
|
||||||
|
bool errorCallbackCalled = false;
|
||||||
|
|
||||||
|
final adConfig = AdsgramAd(
|
||||||
|
blockId: '',
|
||||||
|
rewardAmount: 1,
|
||||||
|
onError: (error) {
|
||||||
|
errorCallbackCalled = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => adsgram.showRewardedAd(adConfig),
|
||||||
|
throwsA(isA<ArgumentError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showAdWithBlockId throws when SDK not available', () async {
|
||||||
|
final adsgram = Adsgram.instance;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => adsgram.showAdWithBlockId('12345'),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('showAdWithBlockId throws when blockId is empty', () async {
|
||||||
|
final adsgram = Adsgram.instance;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
() => adsgram.showAdWithBlockId(''),
|
||||||
|
throwsException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,67 +1,157 @@
|
||||||
// Initialize Adsgram with the configured block ID
|
// Adsgram SDK integration for rewarded ads
|
||||||
const AdController = window.Adsgram.init({ blockId: "16505" });
|
// This file provides a bridge between Dart code and the Adsgram JavaScript SDK
|
||||||
|
|
||||||
// Store reward callback function
|
// Check if Adsgram SDK is loaded
|
||||||
|
if (typeof window.Adsgram === 'undefined') {
|
||||||
|
console.error('Adsgram SDK not loaded. Make sure sad.min.js is included in index.html');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store callbacks for ad lifecycle events
|
||||||
let rewardCallback = null;
|
let rewardCallback = null;
|
||||||
let errorCallback = null;
|
let errorCallback = null;
|
||||||
|
let currentAdController = null;
|
||||||
|
|
||||||
// Function to show ad (called from Dart)
|
// Default block ID (can be overridden)
|
||||||
|
const DEFAULT_BLOCK_ID = "16505";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Adsgram ad controller with a specific block ID
|
||||||
|
* @param {string} blockId - The Adsgram block ID
|
||||||
|
* @returns {object} Adsgram ad controller instance
|
||||||
|
*/
|
||||||
|
function initAdController(blockId) {
|
||||||
|
if (typeof window.Adsgram === 'undefined') {
|
||||||
|
throw new Error('Adsgram SDK not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return window.Adsgram.init({ blockId: blockId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Adsgram controller:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show a rewarded ad using the default block ID
|
||||||
|
* Called from Dart code via js_util.callMethod
|
||||||
|
* @returns {Promise} Promise that resolves when ad completes or rejects on error
|
||||||
|
*/
|
||||||
function showAd() {
|
function showAd() {
|
||||||
return AdController.show().then((result) => {
|
return showAdWithBlockId(DEFAULT_BLOCK_ID);
|
||||||
// user watch ad till the end or close it in interstitial format
|
|
||||||
// your code to reward user for rewarded format
|
|
||||||
console.log('Ad completed successfully', result);
|
|
||||||
|
|
||||||
// Call reward callback if set
|
|
||||||
if (window.rewardCallback && typeof window.rewardCallback === 'function') {
|
|
||||||
window.rewardCallback();
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}).catch((result) => {
|
|
||||||
// user get error during playing ad
|
|
||||||
// do nothing or whatever you want
|
|
||||||
console.error('Ad failed', result);
|
|
||||||
|
|
||||||
// Call error callback if set
|
|
||||||
if (window.errorCallback && typeof window.errorCallback === 'function') {
|
|
||||||
window.errorCallback(JSON.stringify(result, null, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw result;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to show ad with specific block ID
|
/**
|
||||||
|
* Show a rewarded ad with a specific block ID
|
||||||
|
* Called from Dart code via js_util.callMethod
|
||||||
|
* @param {string} blockId - The Adsgram block ID to use
|
||||||
|
* @returns {Promise} Promise that resolves when ad completes or rejects on error
|
||||||
|
*/
|
||||||
function showAdWithBlockId(blockId) {
|
function showAdWithBlockId(blockId) {
|
||||||
const dynamicController = window.Adsgram.init({ blockId: blockId });
|
if (!blockId || typeof blockId !== 'string') {
|
||||||
return dynamicController.show().then((result) => {
|
const error = 'Invalid block ID provided';
|
||||||
console.log('Ad completed successfully for block', blockId, result);
|
console.error(error);
|
||||||
|
if (errorCallback && typeof errorCallback === 'function') {
|
||||||
|
errorCallback(error);
|
||||||
|
}
|
||||||
|
return Promise.reject(new Error(error));
|
||||||
|
}
|
||||||
|
|
||||||
// Call reward callback if set
|
try {
|
||||||
if (window.rewardCallback && typeof window.rewardCallback === 'function') {
|
// Initialize controller for this specific block
|
||||||
window.rewardCallback();
|
currentAdController = initAdController(blockId);
|
||||||
|
|
||||||
|
// Show the ad
|
||||||
|
return currentAdController.show().then((result) => {
|
||||||
|
// Ad completed successfully - user watched till the end
|
||||||
|
console.log('Ad completed successfully', result);
|
||||||
|
|
||||||
|
// Call reward callback if set
|
||||||
|
if (rewardCallback && typeof rewardCallback === 'function') {
|
||||||
|
try {
|
||||||
|
rewardCallback();
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error('Error in reward callback:', callbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}).catch((error) => {
|
||||||
|
// Ad failed or was closed early
|
||||||
|
console.error('Ad failed or was closed:', error);
|
||||||
|
|
||||||
|
// Format error message
|
||||||
|
let errorMessage = 'Ad failed';
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
try {
|
||||||
|
errorMessage = JSON.stringify(error);
|
||||||
|
} catch (e) {
|
||||||
|
errorMessage = error.toString();
|
||||||
|
}
|
||||||
|
} else if (error) {
|
||||||
|
errorMessage = error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call error callback if set
|
||||||
|
if (errorCallback && typeof errorCallback === 'function') {
|
||||||
|
try {
|
||||||
|
errorCallback(errorMessage);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error('Error in error callback:', callbackError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Initialization or show() call failed
|
||||||
|
const errorMessage = error ? error.toString() : 'Failed to show ad';
|
||||||
|
console.error('Failed to show ad:', errorMessage);
|
||||||
|
|
||||||
|
if (errorCallback && typeof errorCallback === 'function') {
|
||||||
|
try {
|
||||||
|
errorCallback(errorMessage);
|
||||||
|
} catch (callbackError) {
|
||||||
|
console.error('Error in error callback:', callbackError);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return Promise.reject(error);
|
||||||
}).catch((result) => {
|
}
|
||||||
console.error('Ad failed for block', blockId, result);
|
|
||||||
|
|
||||||
// Call error callback if set
|
|
||||||
if (window.errorCallback && typeof window.errorCallback === 'function') {
|
|
||||||
window.errorCallback(JSON.stringify(result, null, 4));
|
|
||||||
}
|
|
||||||
|
|
||||||
throw result;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to set reward callback from Dart
|
/**
|
||||||
|
* Set the reward callback function
|
||||||
|
* Called from Dart code to register callback for successful ad completion
|
||||||
|
* @param {Function} callback - Function to call when ad completes successfully
|
||||||
|
*/
|
||||||
function setRewardCallback(callback) {
|
function setRewardCallback(callback) {
|
||||||
window.rewardCallback = callback;
|
if (callback && typeof callback === 'function') {
|
||||||
|
rewardCallback = callback;
|
||||||
|
console.log('Reward callback registered');
|
||||||
|
} else {
|
||||||
|
console.warn('Invalid reward callback provided');
|
||||||
|
rewardCallback = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function to set error callback from Dart
|
/**
|
||||||
|
* Set the error callback function
|
||||||
|
* Called from Dart code to register callback for ad errors
|
||||||
|
* @param {Function} callback - Function to call when ad fails (takes error message as parameter)
|
||||||
|
*/
|
||||||
function setErrorCallback(callback) {
|
function setErrorCallback(callback) {
|
||||||
window.errorCallback = callback;
|
if (callback && typeof callback === 'function') {
|
||||||
|
errorCallback = callback;
|
||||||
|
console.log('Error callback registered');
|
||||||
|
} else {
|
||||||
|
console.warn('Invalid error callback provided');
|
||||||
|
errorCallback = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Make functions available globally for Dart interop
|
||||||
|
window.showAd = showAd;
|
||||||
|
window.showAdWithBlockId = showAdWithBlockId;
|
||||||
|
window.setRewardCallback = setRewardCallback;
|
||||||
|
window.setErrorCallback = setErrorCallback;
|
||||||
Loading…
Reference in a new issue