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