feat(backend): Implement Subscriptions Plans Endpoint
Task ID: BACKEND-001 Priority: high Changes: Completed by: AI Agent Duration: 89312ms
This commit is contained in:
parent
ecbd6e8f4b
commit
f01a06c828
4 changed files with 347 additions and 14 deletions
|
|
@ -1,15 +1,14 @@
|
||||||
{
|
{
|
||||||
"component": "backend",
|
"component": "backend",
|
||||||
"current_task_id": null,
|
"current_task_id": "BACKEND-001",
|
||||||
"iteration_count": 0,
|
"iteration_count": 1,
|
||||||
"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": []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -9,7 +9,7 @@
|
||||||
"id": "BACKEND-001",
|
"id": "BACKEND-001",
|
||||||
"title": "Implement Subscriptions Plans Endpoint",
|
"title": "Implement Subscriptions Plans Endpoint",
|
||||||
"priority": "high",
|
"priority": "high",
|
||||||
"status": "pending",
|
"status": "in_progress",
|
||||||
"estimated_hours": 2.0,
|
"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.",
|
"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": [
|
"acceptance_criteria": [
|
||||||
|
|
@ -65,7 +65,9 @@
|
||||||
"Returns 401 for unauthenticated requests",
|
"Returns 401 for unauthenticated requests",
|
||||||
"Unit test passes for purchase endpoint"
|
"Unit test passes for purchase endpoint"
|
||||||
],
|
],
|
||||||
"dependencies": ["BACKEND-001"],
|
"dependencies": [
|
||||||
|
"BACKEND-001"
|
||||||
|
],
|
||||||
"files_to_modify": [
|
"files_to_modify": [
|
||||||
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
|
@ -87,7 +89,9 @@
|
||||||
"Subscription is marked as cancelled in database",
|
"Subscription is marked as cancelled in database",
|
||||||
"Unit test passes for cancel endpoint"
|
"Unit test passes for cancel endpoint"
|
||||||
],
|
],
|
||||||
"dependencies": ["BACKEND-002"],
|
"dependencies": [
|
||||||
|
"BACKEND-002"
|
||||||
|
],
|
||||||
"files_to_modify": [
|
"files_to_modify": [
|
||||||
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
"mnemo_cards_backend/lib/api/v2/subscriptions_api_v2.dart",
|
||||||
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
"mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart"
|
||||||
|
|
@ -224,7 +228,9 @@
|
||||||
"TODO.md updated with completed tasks",
|
"TODO.md updated with completed tasks",
|
||||||
"Documentation is clear and complete"
|
"Documentation is clear and complete"
|
||||||
],
|
],
|
||||||
"dependencies": ["BACKEND-008"],
|
"dependencies": [
|
||||||
|
"BACKEND-008"
|
||||||
|
],
|
||||||
"files_to_modify": [
|
"files_to_modify": [
|
||||||
"mnemo_cards_backend/STATISTICS_API.md",
|
"mnemo_cards_backend/STATISTICS_API.md",
|
||||||
"mnemo_cards_backend/PROGRESS.md",
|
"mnemo_cards_backend/PROGRESS.md",
|
||||||
|
|
@ -233,4 +239,4 @@
|
||||||
"component": "backend"
|
"component": "backend"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
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/subscription/subscription_manager.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
|
/// 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 +31,34 @@ 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'},
|
||||||
|
);
|
||||||
|
|
||||||
/// 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
|
||||||
|
|
|
||||||
304
mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart
Normal file
304
mnemo_cards_backend/test/api/v2/subscriptions_api_v2_test.dart
Normal 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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue