feat(backend): Add Promocode Validation Endpoint

Task ID: BACKEND-006
Priority: medium

Changes:

Completed by: AI Agent
Duration: 144574ms
This commit is contained in:
AI Agent 2025-11-21 04:34:21 +00:00
parent 849063096c
commit 840e453255
7 changed files with 1067 additions and 333 deletions

View file

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

View file

@ -125,7 +125,7 @@
"id": "BACKEND-006",
"title": "Add Promocode Validation Endpoint",
"priority": "medium",
"status": "pending",
"status": "in_progress",
"estimated_hours": 2.0,
"description": "Implement GET /api/v2/promocodes/{code}/validate endpoint to validate a promocode without applying it. Check if code exists, is active, hasn't exceeded activation limits, and user is eligible.",
"acceptance_criteria": [

View file

@ -68,6 +68,45 @@ class PromocodesApiV2 {
});
}
/// GET /api/v2/promocodes/{code}/validate
/// Validates a promocode without applying it.
/// Returns validation result with valid: true/false and message.
@Route.get('/promocodes/<code>/validate')
Future<Response> validatePromocode(
Request request,
String code,
) async {
final user = request.user;
if (user == null) {
return _unauthorized();
}
if (code.isEmpty) {
return _json(
{
'error': 'bad_request',
'message': 'Promocode is required',
},
statusCode: 400,
);
}
final result = await _promoCodesManager.validatePromocode(code, user);
// Return 404 if code not found
if (!result['valid'] && result['message'] == 'Промокод не найден') {
return _json(
{
'valid': false,
'message': result['message'],
},
statusCode: 404,
);
}
return _json(result);
}
/// POST /api/v2/promocodes/{code}/apply
/// Applies promocode for current user.
@Route.post('/promocodes/<code>/apply')

View file

@ -8,6 +8,16 @@ part of 'promocodes_api_v2.dart';
Router _$PromocodesApiV2Router(PromocodesApiV2 service) {
final router = Router();
router.add(
'GET',
r'/promocodes',
service.listPromocodes,
);
router.add(
'GET',
r'/promocodes/<code>/validate',
service.validatePromocode,
);
router.add(
'POST',
r'/promocodes/<code>/apply',

View file

@ -215,6 +215,130 @@ class PromoCodesManager {
return true;
}
/// Validates a promocode without applying it.
/// Returns a map with 'valid' (bool) and 'message' (String) keys.
Future<Map<String, dynamic>> validatePromocode(
String code,
UserModel user,
) async {
try {
final result = await isar.txn(() async {
final upperCode = code.toUpperCase();
// Check if code exists
final codeModel =
await isar.promoCodeModels.filter().codeEqualTo(upperCode).findFirst();
await codeModel?.campaign.load();
final campaign = codeModel?.campaign.value;
if (codeModel == null || campaign == null) {
return {
'valid': false,
'message': 'Промокод не найден',
};
}
// Check campaign status and date range
final now = DateTime.now();
if (campaign.status != PromoCodeCampaignModelStatus.active) {
return {
'valid': false,
'message': 'Промокод недействителен',
};
}
if (now.isBefore(campaign.start)) {
return {
'valid': false,
'message': 'Промокод еще не активен',
};
}
if (now.isAfter(campaign.finish)) {
return {
'valid': false,
'message': 'Промокод истек',
};
}
// Check if code has reached activation limit
if (codeModel.activations >= campaign.activationsPerCode) {
return {
'valid': false,
'message': 'Промокод исчерпан',
};
}
// Load user data
await user.userData.load();
final userData = user.userData.value;
if (userData == null) {
return {
'valid': false,
'message': 'Ошибка при проверке промокода',
};
}
// Check if user has already activated this code
await userData.activatedPromoCodes.load();
final activatedPromoCodes = userData.activatedPromoCodes;
if (activatedPromoCodes.contains(codeModel)) {
return {
'valid': false,
'message': 'Промокод уже был активирован',
};
}
// Check if user has reached activation limit for this campaign
// Load campaign relationships for activated promocodes
for (final activatedCode in activatedPromoCodes) {
await activatedCode.campaign.load();
}
if (activatedPromoCodes
.where((p) => p.campaign.value?.id == campaign.id)
.length >=
campaign.activationsPerUser) {
return {
'valid': false,
'message': 'Вы уже участвовали в этой акции',
};
}
// Check user tags match campaign tags
if (campaign.tags.isNotEmpty &&
!campaign.tags.hasIntersection(userData.tags)) {
return {
'valid': false,
'message': 'Промокод недействителен',
};
}
// Check if it's an individual promocode for another user
await codeModel.userData.load();
final codeUserData = codeModel.userData.value;
if (codeUserData != null) {
await codeUserData.user.load();
if (codeUserData.user.value?.id != user.id) {
return {
'valid': false,
'message': 'Это промокод для другого пользователя',
};
}
}
// All checks passed
return {
'valid': true,
'message': 'Промокод действителен',
};
});
return result;
} catch (e, s) {
log('Error when validating promo code', error: e, stackTrace: s);
return {
'valid': false,
'message': 'Ошибка при проверке промокода',
};
}
}
Future<PromoCodeDto> applyPromoCode(PromoCodeDto dto, UserModel user) async {
try {
PromoCodeModel? checkedCodeModel;

View file

@ -3,17 +3,17 @@ info:
title: Api
version: 0.0.0
servers:
- url: "https://api.mnemo-cards.online"
- url: "http://localhost:8080"
paths:
/promocodes/<code>/apply:
/purchases/packs/<packId>:
post:
tags:
- PromocodesApiV2
summary: applyPromocode
description: "POST /api/v2/promocodes/{code}/apply\nApplies promocode for current user."
operationId: applyPromocode
- PurchasesApiV2
summary: createPackPurchase
description: "POST /api/v2/purchases/packs/{packId}\nCreate purchase intent for a pack\nReturns purchase info including payment URL for YooKassa"
operationId: createPackPurchase
parameters:
- name: code
- name: packId
in: path
required: true
schema:
@ -21,34 +21,15 @@ paths:
responses:
200:
description: "Operation completed!"
/admin/promocodes:
/purchases/packs/<packId>/status:
get:
tags:
- PromocodesApiV2
summary: listPromoCodeCampaigns
description: GET /api/v2/admin/promocodes\nLists promocode campaigns (admin only).
operationId: listPromoCodeCampaigns
responses:
200:
description: "Operation completed!"
post:
tags:
- PromocodesApiV2
summary: upsertPromoCodeCampaign
description: POST /api/v2/admin/promocodes\nCreates or updates promocode campaign (admin only).
operationId: upsertPromoCodeCampaign
responses:
200:
description: "Operation completed!"
/admin/promocodes/<id>:
get:
tags:
- PromocodesApiV2
summary: getPromoCodeCampaign
description: "GET /api/v2/admin/promocodes/{id}\nReturns promocode campaign details (admin only)."
operationId: getPromoCodeCampaign
- PurchasesApiV2
summary: getPackPurchaseStatus
description: "GET /api/v2/purchases/packs/{packId}/status\nCheck if pack is purchased by the authenticated user"
operationId: getPackPurchaseStatus
parameters:
- name: id
- name: packId
in: path
required: true
schema:
@ -56,14 +37,25 @@ paths:
responses:
200:
description: "Operation completed!"
delete:
/purchases/payments:
post:
tags:
- PromocodesApiV2
summary: deletePromoCodeCampaign
description: "DELETE /api/v2/admin/promocodes/{id}\nDeletes promocode campaign (admin only)."
operationId: deletePromoCodeCampaign
- PurchasesApiV2
summary: createPayment
description: POST /api/v2/purchases/payments\nCreate a payment\nCurrently supports YooKassa for web payments
operationId: createPayment
responses:
200:
description: "Operation completed!"
/purchases/payments/<paymentId>/verify:
get:
tags:
- PurchasesApiV2
summary: verifyPayment
description: "GET /api/v2/purchases/payments/{paymentId}/verify\nVerify payment status\nUpdates user purchases on success"
operationId: verifyPayment
parameters:
- name: id
- name: paymentId
in: path
required: true
schema:
@ -71,104 +63,6 @@ paths:
responses:
200:
description: "Operation completed!"
/admin/discounts:
get:
tags:
- DiscountsApiV2
summary: GET /api/v2/admin/discounts
operationId: listDiscountCampaigns
responses:
200:
description: "Operation completed!"
post:
tags:
- DiscountsApiV2
summary: POST /api/v2/admin/discounts
operationId: addDiscountCampaign
responses:
200:
description: "Operation completed!"
/admin/discounts/<id>:
delete:
tags:
- DiscountsApiV2
summary: "DELETE /api/v2/admin/discounts/{id}"
operationId: deleteDiscountCampaign
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/ads/product/acquire/<key>:
post:
tags:
- AdsApiV2
summary: "POST /api/v2/ads/product/acquire/{key}"
description: Confirms rewarded ad completion and grants product access to the user.
operationId: acquireProductForAd
parameters:
- name: key
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/adsgram/reward:
get:
tags:
- AdsApiV2
summary: "GET /api/v2/adsgram/reward?userId={userId}"
description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad.
operationId: adsgramRewardCallback
responses:
200:
description: "Operation completed!"
/subscriptions/plans:
get:
tags:
- SubscriptionsApiV2
summary: getPlans
description: GET /api/v2/subscriptions/plans\nList available subscription plans
operationId: getPlans
responses:
200:
description: "Operation completed!"
/subscriptions/purchase:
post:
tags:
- SubscriptionsApiV2
summary: purchase
description: POST /api/v2/subscriptions/purchase\nPurchase a subscription
operationId: purchase
responses:
200:
description: "Operation completed!"
/subscriptions/status:
get:
tags:
- SubscriptionsApiV2
summary: getStatus
description: GET /api/v2/subscriptions/status\nGet current user subscription status
operationId: getStatus
responses:
200:
description: "Operation completed!"
/subscriptions/cancel:
post:
tags:
- SubscriptionsApiV2
summary: cancel
description: POST /api/v2/subscriptions/cancel\nCancel user’s subscription
operationId: cancel
responses:
200:
description: "Operation completed!"
/users/me:
get:
tags:
@ -339,185 +233,6 @@ paths:
responses:
200:
description: "Operation completed!"
/games:
get:
tags:
- GamesApiV2
summary: getGames
description: GET /api/v2/games\nGet all available games\nReturns list of games with metadata
operationId: getGames
responses:
200:
description: "Operation completed!"
/games/<gameId>/assets:
get:
tags:
- GamesApiV2
summary: getGameAssets
description: "GET /api/v2/games/{gameId}/assets\nGet game assets\nReturns game assets file (zip) or asset info"
operationId: getGameAssets
parameters:
- name: gameId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs:
get:
tags:
- PacksApiV2
summary: getPacks
description: "GET /api/v2/packs\nGet all pack previews with pagination\nQuery params: ?search=term&language=lang&page=1&limit=20"
operationId: getPacks
responses:
200:
description: "Operation completed!"
/packs/<packId>:
get:
tags:
- PacksApiV2
summary: getPack
description: "GET /api/v2/packs/{packId}\nGet pack details by ID\nReturns full pack details with purchase status if authenticated"
operationId: getPack
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/buy:
get:
tags:
- PacksApiV2
summary: getPackBuyPage
description: "GET /api/v2/packs/{packId}/buy\nReturns pack purchase details (includes rewarded ads offer when available)"
operationId: getPackBuyPage
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards:
get:
tags:
- PacksApiV2
summary: getPackCards
description: "GET /api/v2/packs/{packId}/cards\nGet all cards in a pack\nSupports pagination via query params: ?page=1&limit=20"
operationId: getPackCards
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards/<cardId>/image:
get:
tags:
- PacksApiV2
summary: getCardImage
description: "GET /api/v2/packs/{packId}/cards/{cardId}/image\nGet card image\nReturns PNG image file\n\nImages are accessible for enabled packs even without authentication\nto allow image preview in public pack listings"
operationId: getCardImage
parameters:
- name: packId
in: path
required: true
schema:
type: string
- name: cardId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/tests:
get:
tags:
- PacksApiV2
summary: getPackTests
description: "GET /api/v2/packs/{packId}/tests\nGet tests for a pack"
operationId: getPackTests
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/purchases/packs/<packId>:
post:
tags:
- PurchasesApiV2
summary: createPackPurchase
description: "POST /api/v2/purchases/packs/{packId}\nCreate purchase intent for a pack\nReturns purchase info including payment URL for YooKassa"
operationId: createPackPurchase
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/purchases/packs/<packId>/status:
get:
tags:
- PurchasesApiV2
summary: getPackPurchaseStatus
description: "GET /api/v2/purchases/packs/{packId}/status\nCheck if pack is purchased by the authenticated user"
operationId: getPackPurchaseStatus
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/purchases/payments:
post:
tags:
- PurchasesApiV2
summary: createPayment
description: POST /api/v2/purchases/payments\nCreate a payment\nCurrently supports YooKassa for web payments
operationId: createPayment
responses:
200:
description: "Operation completed!"
/purchases/payments/<paymentId>/verify:
get:
tags:
- PurchasesApiV2
summary: verifyPayment
description: "GET /api/v2/purchases/payments/{paymentId}/verify\nVerify payment status\nUpdates user purchases on success"
operationId: verifyPayment
parameters:
- name: paymentId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/auth/oauth/google:
post:
tags:
@ -662,6 +377,199 @@ paths:
responses:
200:
description: "Operation completed!"
/games:
get:
tags:
- GamesApiV2
summary: getGames
description: GET /api/v2/games\nGet all available games\nReturns list of games with metadata
operationId: getGames
responses:
200:
description: "Operation completed!"
/games/<gameId>/assets:
get:
tags:
- GamesApiV2
summary: getGameAssets
description: "GET /api/v2/games/{gameId}/assets\nGet game assets\nReturns game assets file (zip) or asset info"
operationId: getGameAssets
parameters:
- name: gameId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/admin/discounts:
get:
tags:
- DiscountsApiV2
summary: GET /api/v2/admin/discounts
operationId: listDiscountCampaigns
responses:
200:
description: "Operation completed!"
post:
tags:
- DiscountsApiV2
summary: POST /api/v2/admin/discounts
operationId: addDiscountCampaign
responses:
200:
description: "Operation completed!"
/admin/discounts/<id>:
delete:
tags:
- DiscountsApiV2
summary: "DELETE /api/v2/admin/discounts/{id}"
operationId: deleteDiscountCampaign
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/subscriptions/plans:
get:
tags:
- SubscriptionsApiV2
summary: getPlans
description: GET /api/v2/subscriptions/plans\nList available subscription plans\nReturns all available subscription plans. Authentication is optional.
operationId: getPlans
responses:
200:
description: "Operation completed!"
/subscriptions/purchase:
post:
tags:
- SubscriptionsApiV2
summary: purchase
description: POST /api/v2/subscriptions/purchase\nPurchase a subscription
operationId: purchase
responses:
200:
description: "Operation completed!"
/subscriptions/status:
get:
tags:
- SubscriptionsApiV2
summary: getStatus
description: GET /api/v2/subscriptions/status\nGet current user subscription status
operationId: getStatus
responses:
200:
description: "Operation completed!"
/subscriptions/cancel:
post:
tags:
- SubscriptionsApiV2
summary: cancel
description: POST /api/v2/subscriptions/cancel\nCancel user’s subscription
operationId: cancel
responses:
200:
description: "Operation completed!"
/packs:
get:
tags:
- PacksApiV2
summary: getPacks
description: "GET /api/v2/packs\nGet all pack previews with pagination\nQuery params: ?search=term&language=lang&page=1&limit=20"
operationId: getPacks
responses:
200:
description: "Operation completed!"
/packs/<packId>:
get:
tags:
- PacksApiV2
summary: getPack
description: "GET /api/v2/packs/{packId}\nGet pack details by ID\nReturns full pack details with purchase status if authenticated"
operationId: getPack
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/buy:
get:
tags:
- PacksApiV2
summary: getPackBuyPage
description: "GET /api/v2/packs/{packId}/buy\nReturns pack purchase details (includes rewarded ads offer when available)"
operationId: getPackBuyPage
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards:
get:
tags:
- PacksApiV2
summary: getPackCards
description: "GET /api/v2/packs/{packId}/cards\nGet all cards in a pack\nSupports pagination via query params: ?page=1&limit=20"
operationId: getPackCards
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/cards/<cardId>/image:
get:
tags:
- PacksApiV2
summary: getCardImage
description: "GET /api/v2/packs/{packId}/cards/{cardId}/image\nGet card image\nReturns PNG image file\n\nImages are accessible for enabled packs even without authentication\nto allow image preview in public pack listings"
operationId: getCardImage
parameters:
- name: packId
in: path
required: true
schema:
type: string
- name: cardId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/packs/<packId>/tests:
get:
tags:
- PacksApiV2
summary: getPackTests
description: "GET /api/v2/packs/{packId}/tests\nGet tests for a pack"
operationId: getPackTests
parameters:
- name: packId
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/tasks:
get:
tags:
@ -734,29 +642,147 @@ paths:
responses:
200:
description: "Operation completed!"
/promocodes:
get:
tags:
- PromocodesApiV2
summary: listPromocodes
description: GET /api/v2/promocodes\nLists available promocodes for current user.\nReturns active campaigns with promocodes that user can apply.
operationId: listPromocodes
responses:
200:
description: "Operation completed!"
/promocodes/<code>/validate:
get:
tags:
- PromocodesApiV2
summary: validatePromocode
description: "GET /api/v2/promocodes/{code}/validate\nValidates a promocode without applying it.\nReturns validation result with valid: true/false and message."
operationId: validatePromocode
parameters:
- name: code
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/promocodes/<code>/apply:
post:
tags:
- PromocodesApiV2
summary: applyPromocode
description: "POST /api/v2/promocodes/{code}/apply\nApplies promocode for current user."
operationId: applyPromocode
parameters:
- name: code
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/admin/promocodes:
get:
tags:
- PromocodesApiV2
summary: listPromoCodeCampaigns
description: GET /api/v2/admin/promocodes\nLists promocode campaigns (admin only).
operationId: listPromoCodeCampaigns
responses:
200:
description: "Operation completed!"
post:
tags:
- PromocodesApiV2
summary: upsertPromoCodeCampaign
description: POST /api/v2/admin/promocodes\nCreates or updates promocode campaign (admin only).
operationId: upsertPromoCodeCampaign
responses:
200:
description: "Operation completed!"
/admin/promocodes/<id>:
get:
tags:
- PromocodesApiV2
summary: getPromoCodeCampaign
description: "GET /api/v2/admin/promocodes/{id}\nReturns promocode campaign details (admin only)."
operationId: getPromoCodeCampaign
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
delete:
tags:
- PromocodesApiV2
summary: deletePromoCodeCampaign
description: "DELETE /api/v2/admin/promocodes/{id}\nDeletes promocode campaign (admin only)."
operationId: deletePromoCodeCampaign
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/ads/product/acquire/<key>:
post:
tags:
- AdsApiV2
summary: "POST /api/v2/ads/product/acquire/{key}"
description: Confirms rewarded ad completion and grants product access to the user.
operationId: acquireProductForAd
parameters:
- name: key
in: path
required: true
schema:
type: string
responses:
200:
description: "Operation completed!"
/adsgram/reward:
get:
tags:
- AdsApiV2
summary: "GET /api/v2/adsgram/reward?userId={userId}"
description: Callback endpoint for Adsgram rewarded ad completion.\nThis endpoint is called by Adsgram when a user completes a rewarded ad.
operationId: adsgramRewardCallback
responses:
200:
description: "Operation completed!"
components: { }
tags:
- name: PromocodesApiV2
description: API v2 endpoints for promocode management and activation.
- name: DiscountsApiV2
description: Admin endpoints for discount campaign management.
- name: AdsApiV2
description: API v2 endpoints for rewarded ads flows.
- name: SubscriptionsApiV2
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
- name: PurchasesApiV2
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
- name: UsersApiV2
description: "API v2 endpoints for user profile and self-service operations."
- name: AdminUsersApiV2
description: Admin endpoints for user management in API v2.
- name: GamesApiV2
description: Games API v2\n\nRESTful endpoints for managing games and game assets
- name: PacksApiV2
description: API v2 Packs endpoints\n\nRESTful endpoints for card packs with pagination and filtering
- name: PurchasesApiV2
description: Purchases API v2\n\nRESTful endpoints for managing purchases and payments
- name: AuthApiV2
description: API v2 Authentication endpoints\n\nImplements OAuth2/JWT Bearer token authentication
- name: TestsApiV2
description: Tests API v2\n\nRESTful endpoints for managing tests and test results
- name: GamesApiV2
description: Games API v2\n\nRESTful endpoints for managing games and game assets
- name: DiscountsApiV2
description: Admin endpoints for discount campaign management.
- name: SubscriptionsApiV2
description: "Subscriptions API v2\nRESTful endpoints for managing subscriptions & plans"
- name: PacksApiV2
description: API v2 Packs endpoints\n\nRESTful endpoints for card packs with pagination and filtering
- name: TasksApiV2
description: API v2 endpoints for user tasks management
- name: PromocodesApiV2
description: API v2 endpoints for promocode management and activation.
- name: AdsApiV2
description: API v2 endpoints for rewarded ads flows.

View file

@ -550,4 +550,539 @@ void main() {
expect(campaign['promoCodes'], isA<List>());
});
});
group('GET /api/v2/promocodes/{code}/validate', () {
test('returns 401 for unauthenticated requests', () async {
final request = buildRequest('GET', '/api/v2/promocodes/TEST123/validate');
final response = await promocodesApiV2.validatePromocode(request, 'TEST123');
expect(response.statusCode, equals(401));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('unauthorized'));
});
test('returns 404 for non-existent promocode', () async {
final request = buildRequest(
'GET',
'/api/v2/promocodes/NONEXISTENT/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'NONEXISTENT');
expect(response.statusCode, equals(404));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод не найден'));
});
test('returns 400 for empty promocode', () async {
final request = buildRequest(
'GET',
'/api/v2/promocodes//validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, '');
expect(response.statusCode, equals(400));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['error'], equals('bad_request'));
});
test('returns valid: true for valid promocode', () 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: 'VALID',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'VALID123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/VALID123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'VALID123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
test('returns valid: false for inactive campaign', () 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: 'INACTIVE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.disabled,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'INACTIVE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/INACTIVE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'INACTIVE123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод недействителен'));
});
test('returns valid: false for promocode that has not started', () async {
final now = DateTime.now();
final start = now.add(const Duration(days: 1));
final finish = now.add(const Duration(days: 2));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'FUTURE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'FUTURE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/FUTURE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'FUTURE123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод еще не активен'));
});
test('returns valid: false for expired promocode', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 2));
final finish = now.subtract(const Duration(days: 1));
await testIsar.writeTxn(() async {
final campaign = PromoCodesCampaignModel(
template: 'EXPIRED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'EXPIRED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/EXPIRED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'EXPIRED123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод истек'));
});
test('returns valid: false for promocode 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: 'EXHAUSTED',
products: [],
activationsPerCode: 1,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'EXHAUSTED123', activations: 1);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/EXHAUSTED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'EXHAUSTED123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод исчерпан'));
});
test('returns valid: false for promocode 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: 'USED',
products: [],
activationsPerCode: 10,
activationsPerUser: 10,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'USED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// User has already activated this code
testUserData.activatedPromoCodes.add(promoCode);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/USED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'USED123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод уже был активирован'));
});
test('returns valid: false when user reached campaign 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 promoCode1 = PromoCodeModel(code: 'LIMITED1', activations: 0);
final promoCode2 = PromoCodeModel(code: 'LIMITED2', activations: 0);
await testIsar.promoCodeModels.putAll([promoCode1, promoCode2]);
campaign.promoCodes.addAll([promoCode1, promoCode2]);
await campaign.promoCodes.save();
// User has already activated one code from this campaign
testUserData.activatedPromoCodes.add(promoCode1);
await testUserData.activatedPromoCodes.save();
await testIsar.userDataModels.put(testUserData);
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/LIMITED2/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'LIMITED2');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Вы уже участвовали в этой акции'));
});
test('returns valid: false when user tags do not match campaign 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 {
final campaign = PromoCodesCampaignModel(
template: 'TAGGED',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['vip'], // User has ['premium', 'beta'], no 'vip'
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'TAGGED123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/TAGGED123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'TAGGED123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Промокод недействителен'));
});
test('returns valid: true when user tags match campaign 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 {
final campaign = PromoCodesCampaignModel(
template: 'MATCHING',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: ['premium'], // User has ['premium', 'beta']
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'MATCHING123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/MATCHING123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'MATCHING123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
test('returns valid: false for individual promocode for another user', () async {
final now = DateTime.now();
final start = now.subtract(const Duration(days: 1));
final finish = now.add(const Duration(days: 1));
late UserModel otherUser;
late UserDataModel otherUserData;
await testIsar.writeTxn(() async {
// Create another user
otherUser = UserModel.empty.copyWith(
id: 2,
name: 'Other User',
email: 'other@example.com',
);
await testIsar.userModels.put(otherUser);
otherUserData = UserDataModel(
tags: [],
words: [],
);
await testIsar.userDataModels.put(otherUserData);
otherUserData.user.value = otherUser;
await otherUserData.user.save();
otherUser.userData.value = otherUserData;
await otherUser.userData.save();
final campaign = PromoCodesCampaignModel(
template: 'INDIVIDUAL',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'INDIVIDUAL123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// Assign promocode to other user
promoCode.userData.value = otherUserData;
await promoCode.userData.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/INDIVIDUAL123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'INDIVIDUAL123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(false));
expect(body['message'], equals('Это промокод для другого пользователя'));
});
test('returns valid: true for individual promocode for current 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: 'MYCODE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'MYCODE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
// Assign promocode to current user
promoCode.userData.value = testUserData;
await promoCode.userData.save();
});
final request = buildRequest(
'GET',
'/api/v2/promocodes/MYCODE123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'MYCODE123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
test('handles case-insensitive promocode', () 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: 'CASE',
products: [],
activationsPerCode: 10,
activationsPerUser: 1,
generationSize: 100,
start: start,
finish: finish,
status: PromoCodeCampaignModelStatus.active,
tags: [],
);
await testIsar.promoCodesCampaignModels.put(campaign);
final promoCode = PromoCodeModel(code: 'CASE123', activations: 0);
await testIsar.promoCodeModels.put(promoCode);
campaign.promoCodes.add(promoCode);
await campaign.promoCodes.save();
});
// Request with lowercase code
final request = buildRequest(
'GET',
'/api/v2/promocodes/case123/validate',
user: testUser,
);
final response = await promocodesApiV2.validatePromocode(request, 'case123');
expect(response.statusCode, equals(200));
final body = jsonDecode(await response.readAsString()) as Map<String, dynamic>;
expect(body['valid'], equals(true));
expect(body['message'], equals('Промокод действителен'));
});
});
}