feat(backend): Implement Subscriptions Plans Endpoint

Task ID: BACKEND-001
Priority: high

Changes:

Completed by: AI Agent
Duration: 89312ms
This commit is contained in:
AI Agent 2025-11-21 01:07:48 +00:00
parent ecbd6e8f4b
commit f01a06c828
4 changed files with 347 additions and 14 deletions

View file

@ -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": []
}
}

View file

@ -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"
}
]
}
}

View file

@ -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<String, String> 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<Response> 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

View file

@ -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<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'));
});
});
}