diff --git a/ai_docs/agent/backend/agent_state.json b/ai_docs/agent/backend/agent_state.json index 5891421..bbef145 100644 --- a/ai_docs/agent/backend/agent_state.json +++ b/ai_docs/agent/backend/agent_state.json @@ -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, diff --git a/ai_docs/agent/backend/task_list.json b/ai_docs/agent/backend/task_list.json index b5f2d83..5e83c8f 100644 --- a/ai_docs/agent/backend/task_list.json +++ b/ai_docs/agent/backend/task_list.json @@ -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": [ diff --git a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart index 702a4e2..1b319ec 100644 --- a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart +++ b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.dart @@ -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//validate') + Future 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//apply') diff --git a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.g.dart b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.g.dart index c9a5d71..2e77779 100644 --- a/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.g.dart +++ b/mnemo_cards_backend/lib/api/v2/promocodes_api_v2.g.dart @@ -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//validate', + service.validatePromocode, + ); router.add( 'POST', r'/promocodes//apply', diff --git a/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart b/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart index 6e8c83d..3437e10 100644 --- a/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart +++ b/mnemo_cards_backend/lib/promo_codes/promo_codes_manager.dart @@ -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> 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 applyPromoCode(PromoCodeDto dto, UserModel user) async { try { PromoCodeModel? checkedCodeModel; diff --git a/mnemo_cards_backend/public/open_api.yaml b/mnemo_cards_backend/public/open_api.yaml index c763f2f..899cdc2 100644 --- a/mnemo_cards_backend/public/open_api.yaml +++ b/mnemo_cards_backend/public/open_api.yaml @@ -3,17 +3,17 @@ info: title: Api version: 0.0.0 servers: - - url: "https://api.mnemo-cards.online" + - url: "http://localhost:8080" paths: - /promocodes//apply: + /purchases/packs/: 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//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/: - 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//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/: - 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/: - 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//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/: - 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//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//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//cards//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//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/: - 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//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//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//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/: + 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/: + 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//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//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//cards//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//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//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//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/: + 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/: + 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 \ No newline at end of file + 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. \ No newline at end of file diff --git a/mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart b/mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart index d3cfc79..b636a90 100644 --- a/mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart +++ b/mnemo_cards_backend/test/api/v2/promocodes_api_v2_test.dart @@ -550,4 +550,539 @@ void main() { expect(campaign['promoCodes'], isA()); }); }); + + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + 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; + expect(body['valid'], equals(true)); + expect(body['message'], equals('Промокод действителен')); + }); + }); }