From f01a06c828a5f72a0ec4077a7f66b4b6a8c8748a Mon Sep 17 00:00:00 2001 From: AI Agent Date: Fri, 21 Nov 2025 01:07:48 +0000 Subject: [PATCH] feat(backend): Implement Subscriptions Plans Endpoint Task ID: BACKEND-001 Priority: high Changes: Completed by: AI Agent Duration: 89312ms --- ai_docs/agent/backend/agent_state.json | 11 +- ai_docs/agent/backend/task_list.json | 16 +- .../lib/api/v2/subscriptions_api_v2.dart | 30 +- .../api/v2/subscriptions_api_v2_test.dart | 304 ++++++++++++++++++ 4 files changed, 347 insertions(+), 14 deletions(-) create mode 100644 mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart diff --git a/ai_docs/agent/backend/agent_state.json b/ai_docs/agent/backend/agent_state.json index efa9532..b3ae9b6 100644 --- a/ai_docs/agent/backend/agent_state.json +++ b/ai_docs/agent/backend/agent_state.json @@ -1,15 +1,14 @@ { "component": "backend", - "current_task_id": null, - "iteration_count": 0, + "current_task_id": "BACKEND-001", + "iteration_count": 1, "max_iterations": 10, - "started_at": null, + "started_at": "2025-11-21T01:06:15.789515+00:00", "last_commit": null, "retry_count": 0, "max_retries": 3, - "status": "idle", + "status": "in_progress", "errors": [], "completed_tasks": [], "skipped_tasks": [] -} - +} \ No newline at end of file diff --git a/ai_docs/agent/backend/task_list.json b/ai_docs/agent/backend/task_list.json index 77db130..dd11adc 100644 --- a/ai_docs/agent/backend/task_list.json +++ b/ai_docs/agent/backend/task_list.json @@ -9,7 +9,7 @@ "id": "BACKEND-001", "title": "Implement Subscriptions Plans Endpoint", "priority": "high", - "status": "pending", + "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": [ @@ -65,7 +65,9 @@ "Returns 401 for unauthenticated requests", "Unit test passes for purchase endpoint" ], - "dependencies": ["BACKEND-001"], + "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" @@ -87,7 +89,9 @@ "Subscription is marked as cancelled in database", "Unit test passes for cancel endpoint" ], - "dependencies": ["BACKEND-002"], + "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" @@ -224,7 +228,9 @@ "TODO.md updated with completed tasks", "Documentation is clear and complete" ], - "dependencies": ["BACKEND-008"], + "dependencies": [ + "BACKEND-008" + ], "files_to_modify": [ "mnemo_cards_backend/STATISTICS_API.md", "mnemo_cards_backend/PROGRESS.md", @@ -233,4 +239,4 @@ "component": "backend" } ] -} +} \ No newline at end of file diff --git a/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart b/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart index 90a36c1..3439fe3 100644 --- a/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart @@ -1,4 +1,5 @@ import 'dart:convert'; +import 'dart:developer' as developer; import 'package:injectable/injectable.dart'; import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart'; @@ -12,7 +13,9 @@ part 'subscriptions_api_v2.g.dart'; /// RESTful endpoints for managing subscriptions & plans @lazySingleton class SubscriptionsApiV2 { - SubscriptionsApiV2(SubscriptionManager _subscriptionManager); + final SubscriptionManager _subscriptionManager; + + SubscriptionsApiV2(this._subscriptionManager); Response _ok(Object? object, {Map headers = const {}}) => Response.ok( @@ -28,13 +31,34 @@ class SubscriptionsApiV2 { 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'}, + ); + /// GET /api/v2/subscriptions/plans /// List available subscription plans + /// Returns all available subscription plans. Authentication is optional. @Route.get('/subscriptions/plans') @OpenApiRoute() Future getPlans(Request request) async { - // TODO: implement - return _ok({'plans': []}); + try { + 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 diff --git a/mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart b/mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart new file mode 100644 index 0000000..7aeafe4 --- /dev/null +++ b/mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart @@ -0,0 +1,304 @@ +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; + + expect(response.statusCode, equals(200)); + expect(responseBody['plans'], isA()); + 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; + + expect(response.statusCode, equals(200)); + expect(responseBody['plans'], isA()); + expect(responseBody['plans'], hasLength(2)); + + final plans = responseBody['plans'] as List; + final plan0 = plans[0] as Map; + 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; + + expect(response.statusCode, equals(200)); + expect(responseBody['plans'], isA()); + 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; + + 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; + // 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; + + expect(response.statusCode, equals(200)); + expect(responseBody['plans'], hasLength(2)); + + final plans = responseBody['plans'] as List; + final paymentSystems = plans + .map((p) => (p as Map)['paymentSystem']) + .toList(); + expect(paymentSystems, contains('yookassa')); + expect(paymentSystems, contains('google')); + }); + }); +}