feat(backend): Add Promocodes List Endpoint

Task ID: BACKEND-005
Priority: medium

Changes:

Completed by: AI Agent
Duration: 182145ms
This commit is contained in:
AI Agent 2025-11-21 04:04:56 +00:00
parent eabf5f354a
commit 849063096c
5 changed files with 662 additions and 3 deletions

View file

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

View file

@ -102,7 +102,7 @@
"id": "BACKEND-005",
"title": "Add Promocodes List Endpoint",
"priority": "medium",
"status": "pending",
"status": "in_progress",
"estimated_hours": 2.0,
"description": "Implement GET /api/v2/promocodes endpoint to list available promocodes for current user. Return active campaigns with their promocodes that user can apply. Filter by active status and date range.",
"acceptance_criteria": [

View file

@ -51,6 +51,23 @@ class PromocodesApiV2 {
}
}
/// GET /api/v2/promocodes
/// Lists available promocodes for current user.
/// Returns active campaigns with promocodes that user can apply.
@Route.get('/promocodes')
Future<Response> listPromocodes(Request request) async {
final user = request.user;
if (user == null) {
return _unauthorized();
}
final campaigns =
await _promoCodesManager.listAvailablePromocodes(user);
return _json({
'campaigns': campaigns.map((c) => c.toJson()).toList(),
});
}
/// POST /api/v2/promocodes/{code}/apply
/// Applies promocode for current user.
@Route.post('/promocodes/<code>/apply')

View file

@ -36,6 +36,95 @@ class PromoCodesManager {
return dto;
}
/// Lists available promocodes for a user.
/// Returns active campaigns with promocodes that the user can apply.
Future<List<PromoCodesCampaignDto>> listAvailablePromocodes(
UserModel user,
) async {
await user.userData.load();
final userData = user.userData.value;
if (userData == null) {
return [];
}
await userData.activatedPromoCodes.load();
final activatedPromoCodes = userData.activatedPromoCodes;
final now = DateTime.now();
// Get all active campaigns within date range
// Campaign is active if: now >= start && now <= finish
final activeCampaigns = await isar.txn(() async {
return await isar.promoCodesCampaignModels
.filter()
.statusEqualTo(PromoCodeCampaignModelStatus.active)
.startLessThan(now, include: true)
.finishGreaterThan(now, include: true)
.findAll();
});
final availableCampaigns = <PromoCodesCampaignDto>[];
for (final campaign in activeCampaigns) {
// Check if campaign has tags and user matches them
if (campaign.tags.isNotEmpty &&
!campaign.tags.hasIntersection(userData.tags)) {
continue;
}
// Check if user has already reached activationsPerUser limit
final userActivationsForCampaign = activatedPromoCodes
.where((p) => p.campaign.value?.id == campaign.id)
.length;
if (userActivationsForCampaign >= campaign.activationsPerUser) {
continue;
}
// Load promocodes for this campaign
await campaign.promoCodes.load();
final campaignPromoCodes = campaign.promoCodes.toList();
// Filter available promocodes:
// - Not already activated by user
// - Not reached activationsPerCode limit
// - Not individual promocodes for other users
final availableCodes = <String>[];
for (final promoCode in campaignPromoCodes) {
// Skip if already activated by this user
if (activatedPromoCodes.contains(promoCode)) {
continue;
}
// Skip if reached activation limit
if (promoCode.activations >= campaign.activationsPerCode) {
continue;
}
// Check if it's an individual promocode for another user
await promoCode.userData.load();
final codeUserData = promoCode.userData.value;
if (codeUserData != null) {
await codeUserData.user.load();
if (codeUserData.user.value?.id != user.id) {
continue;
}
}
availableCodes.add(promoCode.code);
}
// Only include campaign if it has available codes
if (availableCodes.isNotEmpty) {
final dto = await campaign.toDto(withCodes: false);
availableCampaigns.add(
dto.copyWith(promoCodes: availableCodes),
);
}
}
return availableCampaigns;
}
Future<void> createProductForAdsCampaignIfNeeded(
MnemoCardsProductModel product,
) async {

View file

@ -0,0 +1,553 @@
import 'dart:convert';
import 'dart:io';
import 'package:isar/isar.dart';
import 'package:mnemo_cards_backend/api/ads/ads_manager.dart';
import 'package:mnemo_cards_backend/api/purchase/payment_manager.dart';
import 'package:mnemo_cards_backend/api/purchase/rustore/rustore_purchase_handler.dart';
import 'package:mnemo_cards_backend/api/purchase/yoo_money.dart';
import 'package:mnemo_cards_backend/api/subscription/subscription_manager.dart';
import 'package:mnemo_cards_backend/api/v2/promocodes_api_v2.dart';
import 'package:mnemo_cards_backend/discounts/discounts_manager.dart';
import 'package:mnemo_cards_backend/main.dart' as backend_main;
import 'package:mnemo_cards_backend/packs/pack_dto_converter.dart';
import 'package:mnemo_cards_backend/packs/pack_manager.dart';
import 'package:mnemo_cards_backend/packs/products_price_resolver.dart';
import 'package:mnemo_cards_backend/promo_codes/promo_codes_manager.dart';
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 PromoCodesManager promoCodesManager;
late PromocodesApiV2 promocodesApiV2;
late UserModel testUser;
late UserDataModel testUserData;
Request buildRequest(
String method,
String url, {
UserModel? user,
}) {
final context = <String, Object?>{};
if (user != null) {
context['user'] = user;
}
final uri = url.startsWith('http') ? Uri.parse(url) : Uri.parse('http://localhost$url');
return Request(method, uri).change(context: context);
}
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
final discountsManager = const DiscountsManager();
final productsPriceResolver = ProductsPriceResolver(discountsManager);
final adsManager = AdsManager();
final packDtoConverter = PackDtoConverter(
productsPriceResolver,
adsManager,
);
final packManager = PackManager(packDtoConverter);
final subscriptionManager = SubscriptionManager();
final yooMoneyHandler = YooMoneyHandler(packManager);
final rustorePurchaseHandler = RustorePurchaseHandler();
final paymentManager = PaymentManager(
packManager,
subscriptionManager,
yooMoneyHandler,
rustorePurchaseHandler,
productsPriceResolver,
);
promoCodesManager = PromoCodesManager(paymentManager);
promocodesApiV2 = PromocodesApiV2(promoCodesManager);
// Create test user with userData
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;
final userData = UserDataModel(
tags: ['premium', 'beta'],
words: [],
);
await testIsar.userDataModels.put(userData);
userData.user.value = user;
await userData.user.save();
testUser.userData.value = userData;
await testUser.userData.save();
testUserData = userData;
});
});
tearDown(() async {
// Clean up test data
await testIsar.writeTxn(() async {
await testIsar.promoCodeModels.clear();
await testIsar.promoCodesCampaignModels.clear();
await testIsar.userDataModels.clear();
await testIsar.userModels.clear();
await testIsar.paymentModels.clear();
});
});
group('GET /api/v2/promocodes', () {
test('returns 401 for unauthenticated requests', () async {
final request = buildRequest('GET', '/api/v2/promocodes');
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(401));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('unauthorized'));
});
test('returns empty list when no campaigns available', () async {
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['campaigns'], isA<List>());
expect((body['campaigns'] as List).isEmpty, isTrue);
});
test('returns active campaigns within date range', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
// Create active campaign
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'TESTXXXX',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
// Create promocodes
final promoCode1 = PromoCodeModel(code: 'TEST1234');
final promoCode2 = PromoCodeModel(code: 'TEST5678');
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['campaigns'], isA<List>());
expect((body['campaigns'] as List).length, equals(1));
final campaign = (body['campaigns'] as List).first as Map<String, dynamic>;
expect(campaign['status'], equals('active'));
expect(campaign['promoCodes'], isA<List>());
expect((campaign['promoCodes'] as List).length, equals(2));
});
test('filters out inactive campaigns', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
// Create inactive campaign
final inactiveCampaign = PromoCodesCampaignModel(
template: 'INACTIVE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.disabled,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(inactiveCampaign);
// Create active campaign
final activeCampaign = PromoCodesCampaignModel(
template: 'ACTIVE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(activeCampaign);
final promoCode = PromoCodeModel(code: 'ACTIVE123');
await testIsar.promoCodeModels.put(promoCode);
activeCampaign.promoCodes.add(promoCode);
await activeCampaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
expect((campaigns.first as Map<String, dynamic>)['template'], equals('ACTIVE'));
});
test('filters out campaigns outside date range', () async {
final now = DateTime.now();
await testIsar.writeTxn(() async {
// Campaign that hasn't started
final futureCampaign = PromoCodesCampaignModel(
template: 'FUTURE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: now.add(const Duration(days: 1)),
finish: now.add(const Duration(days: 2)),
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(futureCampaign);
// Campaign that has finished
final pastCampaign = PromoCodesCampaignModel(
template: 'PAST',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: now.subtract(const Duration(days: 2)),
finish: now.subtract(const Duration(days: 1)),
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(pastCampaign);
// Active campaign within date range
final activeCampaign = PromoCodesCampaignModel(
template: 'ACTIVE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: now.subtract(const Duration(days: 1)),
finish: now.add(const Duration(days: 1)),
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(activeCampaign);
final promoCode = PromoCodeModel(code: 'ACTIVE123');
await testIsar.promoCodeModels.put(promoCode);
activeCampaign.promoCodes.add(promoCode);
await activeCampaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
expect((campaigns.first as Map<String, dynamic>)['template'], equals('ACTIVE'));
});
test('filters campaigns by user tags', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
// Campaign with matching tags
final matchingCampaign = PromoCodesCampaignModel(
template: 'MATCHING',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['premium'],
);
await testIsar.promoCodesCampaignModels.put(matchingCampaign);
// Campaign with non-matching tags
final nonMatchingCampaign = PromoCodesCampaignModel(
template: 'NONMATCHING',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['vip'],
);
await testIsar.promoCodesCampaignModels.put(nonMatchingCampaign);
// Campaign without tags (should be available)
final noTagsCampaign = PromoCodesCampaignModel(
template: 'NOTAGS',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(noTagsCampaign);
final promoCode1 = PromoCodeModel(code: 'MATCH123');
final promoCode2 = PromoCodeModel(code: 'NOTAGS123');
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
matchingCampaign.promoCodes.add(promoCode1);
noTagsCampaign.promoCodes.add(promoCode2);
await matchingCampaign.promoCodes.save();
await noTagsCampaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(2));
final templates = campaigns.map((c) => (c as Map<String, dynamic>)['template']).toList();
expect(templates, contains('MATCHING'));
expect(templates, contains('NOTAGS'));
expect(templates, isNot(contains('NONMATCHING')));
});
test('filters out campaigns where user reached activation limit', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'LIMITED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1, // User can only activate once
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'LIMITED123');
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// User has already activated a promocode from this campaign
testUserData.activatedPromoCodes.add(promoCode);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.isEmpty, isTrue);
});
test('filters out promocodes that reached activation limit', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'LIMITED',
products: [],
activationsPerCode: 1, // Code can only be activated once
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode1 = PromoCodeModel(code: 'LIMITED1', activations: 1); // Already used
final promoCode2 = PromoCodeModel(code: 'LIMITED2', activations: 0); // Available
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
final campaign = campaigns.first as Map<String, dynamic>;
final promoCodes = campaign['promoCodes'] as List;
expect(promoCodes.length, equals(1));
expect(promoCodes.first, equals('LIMITED2'));
});
test('filters out promocodes already activated by user', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'MULTI',
products: [],
activationsPerCode: 10,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode1 = PromoCodeModel(code: 'USED1');
final promoCode2 = PromoCodeModel(code: 'AVAILABLE1');
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
// User has already activated promoCode1
testUserData.activatedPromoCodes.add(promoCode1);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
final campaign = campaigns.first as Map<String, dynamic>;
final promoCodes = campaign['promoCodes'] as List;
expect(promoCodes.length, equals(1));
expect(promoCodes.first, equals('AVAILABLE1'));
});
test('returns proper JSON format with campaign and code information', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'FORMAT',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
name: 'Test Campaign',
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'FORMAT123');
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest('GET', '/api/v2/promocodes', user: testUser);
final response = await promocodesApiV2.listPromocodes(request);
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body.containsKey('campaigns'), isTrue);
final campaigns = body['campaigns'] as List;
expect(campaigns.length, equals(1));
final campaign = campaigns.first as Map<String, dynamic>;
expect(campaign.containsKey('id'), isTrue);
expect(campaign.containsKey('name'), isTrue);
expect(campaign.containsKey('status'), isTrue);
expect(campaign.containsKey('start'), isTrue);
expect(campaign.containsKey('finish'), isTrue);
expect(campaign.containsKey('promoCodes'), isTrue);
expect(campaign['status'], equals('active'));
expect(campaign['promoCodes'], isA<List>());
});
});
}