dbd76d53e8
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads (VKWebAppShowNativeAds) expose only a client-side watch result — no server-to-server verify — so the credit is client-attested, guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10). The caps are both anti-abuse (bounding a forger who skips the ad and calls the endpoint directly) and an economic conversion lever (limiting free chips so a player who wants more buys). D29 amended to VK's reality. Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored by the caps; payout from config rewarded_payout_chips, default 0 = off) + the wallet.reward edge op returning the updated wallet (reward_chips gates the "watch for chips" CTA). Additive migration (two config columns). Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge (vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub (VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary diagnostic that logs the raw VK data, so we confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). Tests: backend integration (credit, nonce idempotency, hourly cap, disabled, non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend, PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
64 lines
2.7 KiB
TypeScript
64 lines
2.7 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import type { Wallet, WalletSegment } from './model';
|
|
import { majorAmount, formatAmount, spendableChips, needsWebSpendWarning } from './wallet';
|
|
|
|
function seg(source: string, chips: number, spendable = true): WalletSegment {
|
|
return { source, chips, spendable };
|
|
}
|
|
|
|
function wallet(segments: WalletSegment[]): Wallet {
|
|
return { segments, adsForever: false, adsPaidUntilMs: 0, hints: 0, rewardChips: 0 };
|
|
}
|
|
|
|
describe('money formatting', () => {
|
|
it('scales roubles by 100 and leaves whole-unit currencies as-is', () => {
|
|
expect(majorAmount(14900, 'RUB')).toBe(149);
|
|
expect(majorAmount(20, 'VOTE')).toBe(20);
|
|
expect(majorAmount(25, 'XTR')).toBe(25);
|
|
});
|
|
|
|
it('formats roubles with two decimals and whole-unit currencies as integers', () => {
|
|
expect(formatAmount(14900, 'RUB')).toBe('149.00');
|
|
expect(formatAmount(15050, 'RUB')).toBe('150.50');
|
|
expect(formatAmount(20, 'VOTE')).toBe('20');
|
|
expect(formatAmount(25, 'XTR')).toBe('25');
|
|
});
|
|
});
|
|
|
|
describe('spendableChips', () => {
|
|
it('sums only the spendable segments', () => {
|
|
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50)]))).toBe(150);
|
|
expect(spendableChips(wallet([seg('direct', 100, false), seg('vk', 50, false)]))).toBe(0);
|
|
expect(spendableChips(wallet([seg('direct', 100), seg('vk', 50, false)]))).toBe(100);
|
|
});
|
|
});
|
|
|
|
describe('needsWebSpendWarning', () => {
|
|
it('does not warn when the direct segment alone covers the price', () => {
|
|
expect(needsWebSpendWarning('direct', [seg('direct', 200), seg('vk', 400)], 150)).toBe(false);
|
|
});
|
|
|
|
it('warns when the priority draw reaches into a store (vk/tg) segment', () => {
|
|
// direct 120 + vk covers the rest of a 300 price → touches vk
|
|
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 400)], 300)).toBe(true);
|
|
});
|
|
|
|
it('warns when only a store segment is spendable and it covers the price', () => {
|
|
expect(needsWebSpendWarning('direct', [seg('vk', 400)], 100)).toBe(true);
|
|
expect(needsWebSpendWarning('direct', [seg('telegram', 400)], 100)).toBe(true);
|
|
});
|
|
|
|
it('does not warn when the price exceeds all spendable chips (buy is disabled anyway)', () => {
|
|
expect(needsWebSpendWarning('direct', [seg('direct', 120), seg('vk', 100)], 500)).toBe(false);
|
|
});
|
|
|
|
it('never warns inside a VK or Telegram context (spend stays in the same store segment)', () => {
|
|
expect(needsWebSpendWarning('vk', [seg('vk', 400)], 100)).toBe(false);
|
|
expect(needsWebSpendWarning('telegram', [seg('telegram', 400)], 100)).toBe(false);
|
|
});
|
|
|
|
it('ignores non-spendable segments (a frozen or untrusted wallet never warns)', () => {
|
|
expect(needsWebSpendWarning('direct', [seg('vk', 400, false)], 100)).toBe(false);
|
|
});
|
|
});
|