feat(backend): Implement Subscription Status Endpoint

Task ID: BACKEND-002
Priority: high

Changes:

Completed by: AI Agent
Duration: 64625ms
This commit is contained in:
AI Agent 2025-11-21 03:33:04 +00:00
parent 311894318d
commit eabf5f354a
4 changed files with 210 additions and 5 deletions

View file

@ -1,7 +1,7 @@
{
"component": "backend",
"current_task_id": "BACKEND-001",
"iteration_count": 1,
"current_task_id": "BACKEND-002",
"iteration_count": 2,
"max_iterations": 10,
"started_at": "2025-11-21T01:06:15.789515+00:00",
"last_commit": null,

View file

@ -30,7 +30,7 @@
"id": "BACKEND-002",
"title": "Implement Subscription Status Endpoint",
"priority": "high",
"status": "pending",
"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": [

View file

@ -2,6 +2,7 @@ import 'dart:convert';
import 'dart:developer' as developer;
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:shelf/shelf.dart';
import 'package:shelf_open_api/shelf_open_api.dart';
@ -40,6 +41,15 @@ class SubscriptionsApiV2 {
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
/// List available subscription plans
/// Returns all available subscription plans. Authentication is optional.
@ -75,8 +85,27 @@ class SubscriptionsApiV2 {
@Route.get('/subscriptions/status')
@OpenApiRoute()
Future<Response> getStatus(Request request) async {
// TODO: implement
return _ok({'active': false});
try {
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

View file

@ -301,4 +301,180 @@ void main() {
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,
);
}
});
});
}