mirror of
https://github.com/mozilla/fxa.git
synced 2025-12-13 20:36:41 +01:00
Merge pull request #19616 from mozilla/pay-3250-add-nimbus
feat(next): add experiments to payments-next
This commit is contained in:
@@ -2,7 +2,10 @@
|
||||
|
||||
echo -e "Starting cirrus experimenter."
|
||||
|
||||
source "$(pwd)/.env"
|
||||
|
||||
CHANNEL_FILE="cirrus.env.development"
|
||||
EXPERIMENTS_FML="${NIMBUS_FML_FILE:-configs/nimbus.yaml}"
|
||||
|
||||
if [[ "${NIMBUS_CIRRUS_CHANNEL}" == "release" ]]; then
|
||||
CHANNEL_FILE="cirrus.env.release"
|
||||
@@ -13,7 +16,7 @@ fi
|
||||
docker run --rm --name cirrus \
|
||||
--net fxa \
|
||||
--mount type=bind,source="$(pwd)/_scripts/configs/${CHANNEL_FILE},target=/cirrus/.env" \
|
||||
--mount type=bind,source="$(pwd)/configs/nimbus.yaml,target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \
|
||||
--mount type=bind,source="$(pwd)/${EXPERIMENTS_FML},target=/cirrus/feature_manifest/frontend-experiments.fml.yml" \
|
||||
--memory=1024m \
|
||||
-p 8001:8001 \
|
||||
mozilla/cirrus:sha-1275f51cb
|
||||
|
||||
@@ -145,6 +145,15 @@ STRIPE_EVENTS_CONFIG__FIRESTORE_STRIPE_EVENT_STORE_COLLECTION_NAME=stripeEvents
|
||||
# Feature flags
|
||||
FEATURE_FLAG_SUB_MANAGE=true
|
||||
|
||||
# Nimbus Client
|
||||
NIMBUS_CLIENT__API_URL=http://localhost:8001/v2/features/
|
||||
NIMBUS_CLIENT__PREVIEW_ENABLED=
|
||||
NIMBUS_CLIENT__TIMEOUT_MS=100
|
||||
|
||||
# Nimbus Manager
|
||||
NIMBUS_MANAGER__ENABLED=true
|
||||
NIMBUS_MANAGER__NAMESPACE=e0066f05-3967-4f6e-8492-03933512611a
|
||||
|
||||
# Other
|
||||
CONTENT_SERVER_URL=http://localhost:3030
|
||||
SUPPORT_URL=https://support.mozilla.org
|
||||
|
||||
@@ -226,7 +226,7 @@ export default async function Checkout({
|
||||
className={clsx(
|
||||
'font-semibold text-grey-600 text-lg mt-10 mb-5',
|
||||
!session?.user?.email &&
|
||||
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
|
||||
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
|
||||
)}
|
||||
data-testid="header-prefix"
|
||||
>
|
||||
@@ -251,7 +251,7 @@ export default async function Checkout({
|
||||
className={clsx(
|
||||
'font-semibold text-grey-600 text-start',
|
||||
!session?.user?.email &&
|
||||
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
|
||||
'cursor-not-allowed relative focus:border-blue-400 focus:outline-none focus:shadow-input-blue-focus after:absolute after:content-[""] after:top-0 after:left-0 after:w-full after:h-full after:bg-white after:opacity-50 after:z-10 select-none'
|
||||
)}
|
||||
>
|
||||
{l10n.getString(
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ManageParams,
|
||||
SubscriptionContent,
|
||||
} from '@fxa/payments/ui';
|
||||
import { getSubManPageContentAction } from '@fxa/payments/ui/actions';
|
||||
import { getExperimentsAction, getSubManPageContentAction } from '@fxa/payments/ui/actions';
|
||||
import { getApp } from '@fxa/payments/ui/server';
|
||||
import alertIcon from '@fxa/shared/assets/images/alert-yellow.svg';
|
||||
import arrowDownIcon from '@fxa/shared/assets/images/arrow-down.svg';
|
||||
@@ -49,6 +49,11 @@ export default async function Manage({
|
||||
|
||||
const userId = session.user.id;
|
||||
|
||||
const experiments = await getExperimentsAction({
|
||||
fxaUid: userId,
|
||||
language: locale,
|
||||
})
|
||||
|
||||
const {
|
||||
accountCreditBalance,
|
||||
defaultPaymentMethod,
|
||||
@@ -77,6 +82,19 @@ export default async function Manage({
|
||||
className="w-full tablet:px-8 desktop:max-w-[1024px]"
|
||||
aria-labelledby="subscription-management"
|
||||
>
|
||||
{experiments?.Features['welcome-feature']?.enabled && (
|
||||
<Banner variant={BannerVariant.Success}>
|
||||
<div className="leading-6 text-base">
|
||||
<p className="font-bold">
|
||||
Welcome
|
||||
</p>
|
||||
|
||||
<p className="font-normal">
|
||||
Welcome to the new and redesigned Subscription Management Page!
|
||||
</p>
|
||||
</div>
|
||||
</Banner>
|
||||
)}
|
||||
{isPaypalBillingAgreementError && (
|
||||
<Banner variant={BannerVariant.Error}>
|
||||
<div className="leading-6 text-base">
|
||||
@@ -148,46 +166,25 @@ export default async function Manage({
|
||||
{(subscriptions.length > 0 ||
|
||||
appleIapSubscriptions.length > 0 ||
|
||||
googleIapSubscriptions.length > 0) && (
|
||||
<nav
|
||||
className="px-4 tablet:hidden"
|
||||
aria-labelledby="mobile-quick-links-menu"
|
||||
>
|
||||
<h2 id="mobile-quick-links-menu" className="font-bold my-6">
|
||||
{l10n.getString(
|
||||
'subscription-management-jump-to-heading',
|
||||
'Jump to'
|
||||
)}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-6">
|
||||
<li>
|
||||
<Link
|
||||
className="flex items-center justify-between text-blue-500 hover:text-blue-600 cursor-pointer underline"
|
||||
href="#payment-details"
|
||||
>
|
||||
{l10n.getString(
|
||||
'subscription-management-nav-payment-details',
|
||||
'Payment details'
|
||||
)}
|
||||
<Image
|
||||
src={arrowDownIcon}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
{(subscriptions.length > 0 ||
|
||||
appleIapSubscriptions.length > 0 ||
|
||||
googleIapSubscriptions.length > 0) && (
|
||||
<nav
|
||||
className="px-4 tablet:hidden"
|
||||
aria-labelledby="mobile-quick-links-menu"
|
||||
>
|
||||
<h2 id="mobile-quick-links-menu" className="font-bold my-6">
|
||||
{l10n.getString(
|
||||
'subscription-management-jump-to-heading',
|
||||
'Jump to'
|
||||
)}
|
||||
</h2>
|
||||
<ul className="flex flex-col gap-6">
|
||||
<li>
|
||||
<Link
|
||||
className="flex items-center justify-between text-blue-500 hover:text-blue-600 cursor-pointer underline"
|
||||
href="#active-subscriptions"
|
||||
href="#payment-details"
|
||||
>
|
||||
{l10n.getString(
|
||||
'subscription-management-nav-active-subscriptions',
|
||||
'Active subscriptions'
|
||||
'subscription-management-nav-payment-details',
|
||||
'Payment details'
|
||||
)}
|
||||
<Image
|
||||
src={arrowDownIcon}
|
||||
@@ -198,10 +195,31 @@ export default async function Manage({
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
{(subscriptions.length > 0 ||
|
||||
appleIapSubscriptions.length > 0 ||
|
||||
googleIapSubscriptions.length > 0) && (
|
||||
<li>
|
||||
<Link
|
||||
className="flex items-center justify-between text-blue-500 hover:text-blue-600 cursor-pointer underline"
|
||||
href="#active-subscriptions"
|
||||
>
|
||||
{l10n.getString(
|
||||
'subscription-management-nav-active-subscriptions',
|
||||
'Active subscriptions'
|
||||
)}
|
||||
<Image
|
||||
src={arrowDownIcon}
|
||||
alt=""
|
||||
width={12}
|
||||
height={12}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<section
|
||||
id="payment-details"
|
||||
@@ -319,13 +337,13 @@ export default async function Manage({
|
||||
alt={
|
||||
walletType === 'apple_pay'
|
||||
? l10n.getString(
|
||||
'apple-pay-logo-alt-text',
|
||||
'Apple Pay logo'
|
||||
)
|
||||
'apple-pay-logo-alt-text',
|
||||
'Apple Pay logo'
|
||||
)
|
||||
: l10n.getString(
|
||||
'google-pay-logo-alt-text',
|
||||
'Google Pay logo'
|
||||
)
|
||||
'google-pay-logo-alt-text',
|
||||
'Google Pay logo'
|
||||
)
|
||||
}
|
||||
width={45}
|
||||
height={24}
|
||||
@@ -519,126 +537,53 @@ export default async function Manage({
|
||||
{(subscriptions.length > 0 ||
|
||||
appleIapSubscriptions.length > 0 ||
|
||||
googleIapSubscriptions.length > 0) && (
|
||||
<>
|
||||
<ul
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-your-active-subscriptions-aria',
|
||||
'Your active subscriptions'
|
||||
)}
|
||||
>
|
||||
{subscriptions.map((sub, index: number) => {
|
||||
return (
|
||||
<li
|
||||
key={`${sub.productName}-${index}`}
|
||||
aria-labelledby={`${sub.productName}-information`}
|
||||
className="leading-6 pb-4"
|
||||
>
|
||||
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
|
||||
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
|
||||
<div className="tablet:min-w-[160px]">
|
||||
<Image
|
||||
src={sub.webIcon}
|
||||
alt={sub.productName}
|
||||
height={64}
|
||||
width={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between mt-4 tablet:mt-0">
|
||||
<div>
|
||||
<h3
|
||||
id={`${sub.productName}-information`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{sub.productName}
|
||||
</h3>
|
||||
<p className="text-grey-500">
|
||||
{sub.interval &&
|
||||
formatPlanInterval(sub.interval)}
|
||||
</p>
|
||||
</div>
|
||||
<LinkExternal
|
||||
href={sub.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-support-aria',
|
||||
{ productName: sub.productName },
|
||||
`Get help for ${sub.productName}`
|
||||
)}
|
||||
data-testid={`link-external-support-${sub.productName}`}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-support',
|
||||
'Get help'
|
||||
)}
|
||||
</span>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
<SubscriptionContent
|
||||
userId={userId}
|
||||
subscription={sub}
|
||||
locale={locale}
|
||||
supportUrl={sub.supportUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{appleIapSubscriptions.length > 0 && (
|
||||
<>
|
||||
<ul
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-your-apple-iap-subscriptions-aria',
|
||||
'Your Apple In-App Subscriptions'
|
||||
'subscription-management-your-active-subscriptions-aria',
|
||||
'Your active subscriptions'
|
||||
)}
|
||||
>
|
||||
{appleIapSubscriptions.map((purchase, index: number) => {
|
||||
let nextBillDate: string | undefined;
|
||||
if (purchase.expiresDate) {
|
||||
const dateExpired = new Date(purchase.expiresDate);
|
||||
nextBillDate = l10n.getLocalizedDateString(
|
||||
Math.floor(dateExpired.getTime() / 1000),
|
||||
false,
|
||||
locale
|
||||
);
|
||||
}
|
||||
{subscriptions.map((sub, index: number) => {
|
||||
return (
|
||||
<li
|
||||
key={`${purchase.storeId}-${index}`}
|
||||
aria-labelledby={`${purchase.productName}-heading`}
|
||||
key={`${sub.productName}-${index}`}
|
||||
aria-labelledby={`${sub.productName}-information`}
|
||||
className="leading-6 pb-4"
|
||||
>
|
||||
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
|
||||
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
|
||||
<div className="tablet:min-w-[160px]">
|
||||
<Image
|
||||
src={purchase.webIcon}
|
||||
alt={purchase.productName}
|
||||
src={sub.webIcon}
|
||||
alt={sub.productName}
|
||||
height={64}
|
||||
width={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between mt-4 tablet:mt-0">
|
||||
<h3
|
||||
id={`${purchase.productName}-heading`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{purchase.productName}
|
||||
</h3>
|
||||
<div>
|
||||
<h3
|
||||
id={`${sub.productName}-information`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{sub.productName}
|
||||
</h3>
|
||||
<p className="text-grey-500">
|
||||
{sub.interval &&
|
||||
formatPlanInterval(sub.interval)}
|
||||
</p>
|
||||
</div>
|
||||
<LinkExternal
|
||||
href={purchase.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
href={sub.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-support-aria',
|
||||
{ productName: purchase.productName },
|
||||
`Get help for ${purchase.productName}`
|
||||
{ productName: sub.productName },
|
||||
`Get help for ${sub.productName}`
|
||||
)}
|
||||
data-testid={`link-external-support-${purchase.productName}`}
|
||||
data-testid={`link-external-support-${sub.productName}`}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
@@ -648,77 +593,12 @@ export default async function Manage({
|
||||
</span>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
<div className="bg-grey-10 leading-6 p-4 rounded-lg">
|
||||
<div className="flex items-center -my-2 -mx-3">
|
||||
<Image
|
||||
src={iapAppleLogo}
|
||||
alt=""
|
||||
width={46}
|
||||
height={46}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{l10n.getString(
|
||||
'subscription-management-apple-in-app-purchase-2',
|
||||
'Apple in-app purchase'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{nextBillDate && (
|
||||
<>
|
||||
<div
|
||||
className="border-none h-px bg-grey-100 my-2"
|
||||
role="separator"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Image
|
||||
src={alertIcon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm text-yellow-800">
|
||||
{l10n.getString(
|
||||
'subscription-management-iap-sub-expires-on-expiry-date',
|
||||
{
|
||||
date: nextBillDate,
|
||||
},
|
||||
`Expires on ${nextBillDate}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end w-full tablet:w-auto">
|
||||
<LinkExternal
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
href={`https://apps.apple.com/account/subscriptions`}
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-manage-subscription-aria',
|
||||
{
|
||||
productName: purchase.productName,
|
||||
},
|
||||
`Manage subscription for ${purchase.productName}`
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-manage-subscription-1',
|
||||
'Manage subscription'
|
||||
)}
|
||||
</span>
|
||||
<Image
|
||||
src={newWindowIcon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
<SubscriptionContent
|
||||
userId={userId}
|
||||
subscription={sub}
|
||||
locale={locale}
|
||||
supportUrl={sub.supportUrl}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -726,98 +606,89 @@ export default async function Manage({
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{googleIapSubscriptions.length > 0 && (
|
||||
<ul
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-your-google-iap-subscriptions-aria',
|
||||
'Your Google In-App Subscriptions'
|
||||
)}
|
||||
>
|
||||
{googleIapSubscriptions.map((purchase, index: number) => {
|
||||
const nextBillDate = l10n.getLocalizedDateString(
|
||||
purchase.expiryTimeMillis / 1000,
|
||||
false,
|
||||
locale
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${purchase.storeId}-${index}`}
|
||||
aria-labelledby={`${purchase.productName}-heading`}
|
||||
className="leading-6 pb-4"
|
||||
>
|
||||
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
|
||||
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
|
||||
<div className="tablet:min-w-[160px]">
|
||||
<Image
|
||||
src={purchase.webIcon}
|
||||
alt={purchase.productName}
|
||||
height={64}
|
||||
width={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between mt-4 tablet:mt-0">
|
||||
<h3
|
||||
id={`${purchase.productName}-heading`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{purchase.productName}
|
||||
</h3>
|
||||
<LinkExternal
|
||||
href={purchase.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-support-aria',
|
||||
{ productName: purchase.productName },
|
||||
`Get help for ${purchase.productName}`
|
||||
)}
|
||||
data-testid={`link-external-support-${purchase.productName}`}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-support',
|
||||
'Get help'
|
||||
)}
|
||||
</span>
|
||||
</LinkExternal>
|
||||
{appleIapSubscriptions.length > 0 && (
|
||||
<ul
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-your-apple-iap-subscriptions-aria',
|
||||
'Your Apple In-App Subscriptions'
|
||||
)}
|
||||
>
|
||||
{appleIapSubscriptions.map((purchase, index: number) => {
|
||||
let nextBillDate: string | undefined;
|
||||
if (purchase.expiresDate) {
|
||||
const dateExpired = new Date(purchase.expiresDate);
|
||||
nextBillDate = l10n.getLocalizedDateString(
|
||||
Math.floor(dateExpired.getTime() / 1000),
|
||||
false,
|
||||
locale
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li
|
||||
key={`${purchase.storeId}-${index}`}
|
||||
aria-labelledby={`${purchase.productName}-heading`}
|
||||
className="leading-6 pb-4"
|
||||
>
|
||||
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
|
||||
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
|
||||
<div className="tablet:min-w-[160px]">
|
||||
<Image
|
||||
src={purchase.webIcon}
|
||||
alt={purchase.productName}
|
||||
height={64}
|
||||
width={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-grey-10 leading-6 p-4 rounded-lg">
|
||||
<div className="flex items-center -my-2 -mx-3">
|
||||
<Image
|
||||
src={iapGoogleLogo}
|
||||
alt=""
|
||||
width={46}
|
||||
height={46}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{l10n.getString(
|
||||
'subscription-management-google-in-app-purchase-2',
|
||||
'Google in-app purchase'
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between mt-4 tablet:mt-0">
|
||||
<h3
|
||||
id={`${purchase.productName}-heading`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{purchase.productName}
|
||||
</h3>
|
||||
<LinkExternal
|
||||
href={purchase.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-support-aria',
|
||||
{ productName: purchase.productName },
|
||||
`Get help for ${purchase.productName}`
|
||||
)}
|
||||
</p>
|
||||
data-testid={`link-external-support-${purchase.productName}`}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-support',
|
||||
'Get help'
|
||||
)}
|
||||
</span>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
{!!purchase.expiryTimeMillis && (
|
||||
<>
|
||||
<div
|
||||
className="border-none h-px bg-grey-100 my-2"
|
||||
role="separator"
|
||||
<div className="bg-grey-10 leading-6 p-4 rounded-lg">
|
||||
<div className="flex items-center -my-2 -mx-3">
|
||||
<Image
|
||||
src={iapAppleLogo}
|
||||
alt=""
|
||||
width={46}
|
||||
height={46}
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
{purchase.autoRenewing ? (
|
||||
<p className="text-grey-500 text-sm">
|
||||
{l10n.getFragmentWithSource(
|
||||
'subscription-management-iap-sub-next-bill-1',
|
||||
{
|
||||
vars: { date: nextBillDate },
|
||||
},
|
||||
<p>Next bill • {nextBillDate}</p>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
/>
|
||||
<p>
|
||||
{l10n.getString(
|
||||
'subscription-management-apple-in-app-purchase-2',
|
||||
'Apple in-app purchase'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{nextBillDate && (
|
||||
<>
|
||||
<div
|
||||
className="border-none h-px bg-grey-100 my-2"
|
||||
role="separator"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Image
|
||||
src={alertIcon}
|
||||
@@ -836,49 +707,196 @@ export default async function Manage({
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end w-full tablet:w-auto">
|
||||
<LinkExternal
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
href={`https://play.google.com/store/account/subscriptions?sku=${encodeURIComponent(
|
||||
purchase.sku
|
||||
)}&package=${encodeURIComponent(purchase.packageName)}`}
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-manage-subscription-aria',
|
||||
{
|
||||
productName: purchase.productName,
|
||||
},
|
||||
`Manage subscription for ${purchase.productName}`
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-manage-subscription-1',
|
||||
'Manage subscription'
|
||||
</div>
|
||||
<div className="flex justify-end w-full tablet:w-auto">
|
||||
<LinkExternal
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
href={`https://apps.apple.com/account/subscriptions`}
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-manage-subscription-aria',
|
||||
{
|
||||
productName: purchase.productName,
|
||||
},
|
||||
`Manage subscription for ${purchase.productName}`
|
||||
)}
|
||||
</span>
|
||||
<Image
|
||||
src={newWindowIcon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</LinkExternal>
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-manage-subscription-1',
|
||||
'Manage subscription'
|
||||
)}
|
||||
</span>
|
||||
<Image
|
||||
src={newWindowIcon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{googleIapSubscriptions.length > 0 && (
|
||||
<ul
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-your-google-iap-subscriptions-aria',
|
||||
'Your Google In-App Subscriptions'
|
||||
)}
|
||||
>
|
||||
{googleIapSubscriptions.map((purchase, index: number) => {
|
||||
const nextBillDate = l10n.getLocalizedDateString(
|
||||
purchase.expiryTimeMillis / 1000,
|
||||
false,
|
||||
locale
|
||||
);
|
||||
return (
|
||||
<li
|
||||
key={`${purchase.storeId}-${index}`}
|
||||
aria-labelledby={`${purchase.productName}-heading`}
|
||||
className="leading-6 pb-4"
|
||||
>
|
||||
<div className="w-full py-6 text-grey-600 bg-white rounded-xl border border-grey-200 opacity-100 shadow-[0_0_16px_0_rgba(0,0,0,0.08)] tablet:px-6 tablet:py-8">
|
||||
<div className="flex flex-col px-4 tablet:px-0 tablet:flex-row tablet:items-start">
|
||||
<div className="tablet:min-w-[160px]">
|
||||
<Image
|
||||
src={purchase.webIcon}
|
||||
alt={purchase.productName}
|
||||
height={64}
|
||||
width={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex items-start justify-between mt-4 tablet:mt-0">
|
||||
<h3
|
||||
id={`${purchase.productName}-heading`}
|
||||
className="font-bold text-lg"
|
||||
>
|
||||
{purchase.productName}
|
||||
</h3>
|
||||
<LinkExternal
|
||||
href={purchase.supportUrl}
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-support-aria',
|
||||
{ productName: purchase.productName },
|
||||
`Get help for ${purchase.productName}`
|
||||
)}
|
||||
data-testid={`link-external-support-${purchase.productName}`}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-support',
|
||||
'Get help'
|
||||
)}
|
||||
</span>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
<div className="bg-grey-10 leading-6 p-4 rounded-lg">
|
||||
<div className="flex items-center -my-2 -mx-3">
|
||||
<Image
|
||||
src={iapGoogleLogo}
|
||||
alt=""
|
||||
width={46}
|
||||
height={46}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p>
|
||||
{l10n.getString(
|
||||
'subscription-management-google-in-app-purchase-2',
|
||||
'Google in-app purchase'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{!!purchase.expiryTimeMillis && (
|
||||
<>
|
||||
<div
|
||||
className="border-none h-px bg-grey-100 my-2"
|
||||
role="separator"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
|
||||
{purchase.autoRenewing ? (
|
||||
<p className="text-grey-500 text-sm">
|
||||
{l10n.getFragmentWithSource(
|
||||
'subscription-management-iap-sub-next-bill-1',
|
||||
{
|
||||
vars: { date: nextBillDate },
|
||||
},
|
||||
<p>Next bill • {nextBillDate}</p>
|
||||
)}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<Image
|
||||
src={alertIcon}
|
||||
alt=""
|
||||
width={20}
|
||||
height={20}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<p className="text-sm text-yellow-800">
|
||||
{l10n.getString(
|
||||
'subscription-management-iap-sub-expires-on-expiry-date',
|
||||
{
|
||||
date: nextBillDate,
|
||||
},
|
||||
`Expires on ${nextBillDate}`
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end w-full tablet:w-auto">
|
||||
<LinkExternal
|
||||
className="text-blue-500 hover:text-blue-600 cursor-pointer flex items-center gap-1 flex-shrink-0 overflow-hidden text-ellipsis underline whitespace-nowrap"
|
||||
href={`https://play.google.com/store/account/subscriptions?sku=${encodeURIComponent(
|
||||
purchase.sku
|
||||
)}&package=${encodeURIComponent(purchase.packageName)}`}
|
||||
aria-label={l10n.getString(
|
||||
'subscription-management-button-manage-subscription-aria',
|
||||
{
|
||||
productName: purchase.productName,
|
||||
},
|
||||
`Manage subscription for ${purchase.productName}`
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{l10n.getString(
|
||||
'subscription-management-button-manage-subscription-1',
|
||||
'Manage subscription'
|
||||
)}
|
||||
</span>
|
||||
<Image
|
||||
src={newWindowIcon}
|
||||
alt=""
|
||||
width={16}
|
||||
height={16}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</LinkExternal>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -69,6 +69,16 @@ export function middleware(request: NextRequest) {
|
||||
contentSecurityPolicyHeaderValue
|
||||
);
|
||||
|
||||
// If the user is not logged in, `getExperimentationId` uses the value we
|
||||
// set here to determine which experiments to show the user. We read it from
|
||||
// the `experimentationId` cookie, and initialise that cookie if it's unset.
|
||||
// (The reason we do this in middleware, is to ensure that every call to
|
||||
// `getExperimentationId` results in the same ID.)
|
||||
const existingExperimentationId = request.cookies.get('experimentationId');
|
||||
const experimentationId =
|
||||
existingExperimentationId?.value ?? `guest-${crypto.randomUUID()}`;
|
||||
requestHeaders.set('x-experimentation-id', experimentationId);
|
||||
|
||||
const response = NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
@@ -79,6 +89,12 @@ export function middleware(request: NextRequest) {
|
||||
contentSecurityPolicyHeaderValue
|
||||
);
|
||||
|
||||
response.cookies.set({
|
||||
name: 'experimentationId',
|
||||
value: experimentationId,
|
||||
path: '/',
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,21 @@ import { SubplatInterval } from '@fxa/payments/customer';
|
||||
export const AuthEventsFactory = (
|
||||
override?: Partial<AuthEvents>
|
||||
): AuthEvents => ({
|
||||
type: faker.helpers.arrayElement(['signin', 'signout', 'prompt_none_fail', 'error']),
|
||||
...override
|
||||
})
|
||||
type: faker.helpers.arrayElement([
|
||||
'signin',
|
||||
'signout',
|
||||
'prompt_none_fail',
|
||||
'error',
|
||||
]),
|
||||
...override,
|
||||
});
|
||||
|
||||
export const AdditionalMetricsDataFactory = (
|
||||
override?: AdditionalMetricsData
|
||||
): AdditionalMetricsData => ({
|
||||
cmsMetricsData: CmsMetricsDataFactory(),
|
||||
cartMetricsData: CartMetricsFactory(),
|
||||
locale: faker.helpers.arrayElement(['en-US', 'de', 'es', 'fr-FR']),
|
||||
...override,
|
||||
});
|
||||
|
||||
|
||||
@@ -64,6 +64,16 @@ import {
|
||||
PaypalClientConfig,
|
||||
PaypalCustomerManager,
|
||||
} from '@fxa/payments/paypal';
|
||||
import {
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusClient,
|
||||
NimbusEnrollmentFactory,
|
||||
} from '@fxa/shared/experiments';
|
||||
import {
|
||||
MockNimbusManagerConfigProvider,
|
||||
NimbusManager,
|
||||
SubPlatNimbusResultFactory,
|
||||
} from '@fxa/payments/experiments';
|
||||
|
||||
jest.mock('./util/retrieveAdditionalMetricsData');
|
||||
const mockedRetrieveAdditionalMetricsData = jest.mocked(
|
||||
@@ -78,6 +88,7 @@ describe('PaymentsEmitterService', () => {
|
||||
let paymentsGleanManager: PaymentsGleanManager;
|
||||
let paymentMethodManager: PaymentMethodManager;
|
||||
let productConfigurationManager: ProductConfigurationManager;
|
||||
let nimbusManager: NimbusManager;
|
||||
let statsd: StatsD;
|
||||
let logger: Logger;
|
||||
let subscriptionManager: SubscriptionManager;
|
||||
@@ -125,6 +136,10 @@ describe('PaymentsEmitterService', () => {
|
||||
PaymentsEmitterService,
|
||||
PaymentMethodManager,
|
||||
SubscriptionManager,
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusManager,
|
||||
MockNimbusManagerConfigProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -134,6 +149,7 @@ describe('PaymentsEmitterService', () => {
|
||||
paymentsGleanManager = moduleRef.get(PaymentsGleanManager);
|
||||
paymentMethodManager = moduleRef.get(PaymentMethodManager);
|
||||
productConfigurationManager = moduleRef.get(ProductConfigurationManager);
|
||||
nimbusManager = moduleRef.get(NimbusManager);
|
||||
cartManager = moduleRef.get(CartManager);
|
||||
statsd = moduleRef.get<StatsD>(StatsDService);
|
||||
logger = moduleRef.get<Logger>(Logger);
|
||||
@@ -182,10 +198,23 @@ describe('PaymentsEmitterService', () => {
|
||||
});
|
||||
|
||||
describe('handleCheckoutView', () => {
|
||||
const mockEnrollment = NimbusEnrollmentFactory();
|
||||
const mockSubPlatExperiments = SubPlatNimbusResultFactory({
|
||||
Enrollments: [mockEnrollment],
|
||||
});
|
||||
const nimbusUserId = mockEnrollment.nimbus_user_id;
|
||||
const generatedNimbusUserId = NimbusEnrollmentFactory().nimbus_user_id;
|
||||
|
||||
beforeEach(() => {
|
||||
jest
|
||||
.spyOn(paymentsGleanManager, 'recordFxaPaySetupView')
|
||||
.mockReturnValue();
|
||||
jest
|
||||
.spyOn(nimbusManager, 'fetchExperiments')
|
||||
.mockResolvedValue(mockSubPlatExperiments);
|
||||
jest
|
||||
.spyOn(nimbusManager, 'generateNimbusId')
|
||||
.mockReturnValue(generatedNimbusUserId);
|
||||
});
|
||||
|
||||
it('should call manager record method', async () => {
|
||||
@@ -198,8 +227,11 @@ describe('PaymentsEmitterService', () => {
|
||||
);
|
||||
expect(paymentsGleanManager.recordFxaPaySetupView).toHaveBeenCalledWith({
|
||||
commonMetricsData: mockCommonMetricsData,
|
||||
experimentationData: { nimbusUserId },
|
||||
...additionalMetricsData,
|
||||
});
|
||||
expect(nimbusManager.fetchExperiments).toHaveBeenCalled();
|
||||
expect(nimbusManager.generateNimbusId).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not record glean event if user opts out', async () => {
|
||||
@@ -215,6 +247,8 @@ describe('PaymentsEmitterService', () => {
|
||||
mockCommonMetricsData.params
|
||||
);
|
||||
expect(paymentsGleanManager.recordFxaPaySetupView).not.toHaveBeenCalled();
|
||||
expect(nimbusManager.fetchExperiments).not.toHaveBeenCalled();
|
||||
expect(nimbusManager.generateNimbusId).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
import * as Sentry from '@sentry/nestjs';
|
||||
import { StatsD, StatsDService } from '@fxa/shared/metrics/statsd';
|
||||
import { EmitterServiceHandleAuthError } from './emitter.error';
|
||||
import { NimbusManager } from '@fxa/payments/experiments';
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsEmitterService {
|
||||
@@ -37,6 +38,7 @@ export class PaymentsEmitterService {
|
||||
private cartManager: CartManager,
|
||||
private customerManager: CustomerManager,
|
||||
private log: Logger,
|
||||
private nimbusManager: NimbusManager,
|
||||
private paymentsGleanManager: PaymentsGleanManager,
|
||||
private paymentMethodManager: PaymentMethodManager,
|
||||
private productConfigurationManager: ProductConfigurationManager,
|
||||
@@ -83,9 +85,30 @@ export class PaymentsEmitterService {
|
||||
);
|
||||
|
||||
if (!metricsOptOut) {
|
||||
let experiments;
|
||||
const generatedNimbusUserId = this.nimbusManager.generateNimbusId(
|
||||
additionalData.cartMetricsData.uid,
|
||||
eventData.experimentationId
|
||||
);
|
||||
try {
|
||||
experiments = await this.nimbusManager.fetchExperiments({
|
||||
nimbusUserId: generatedNimbusUserId,
|
||||
language: additionalData.locale,
|
||||
region: additionalData.cartMetricsData.taxAddress?.countryCode,
|
||||
});
|
||||
} catch (error) {
|
||||
this.log.error(error);
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
const nimbusUserId =
|
||||
experiments?.Enrollments?.at(0)?.nimbus_user_id ||
|
||||
generatedNimbusUserId;
|
||||
|
||||
this.paymentsGleanManager.recordFxaPaySetupView({
|
||||
commonMetricsData: eventData,
|
||||
...additionalData,
|
||||
experimentationData: { nimbusUserId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,7 @@ import {
|
||||
} from '@fxa/payments/metrics';
|
||||
import { LocationStatus } from '@fxa/payments/eligibility';
|
||||
import { TaxChangeAllowedStatus } from '@fxa/payments/cart';
|
||||
import {
|
||||
SubPlatPaymentMethodType,
|
||||
} from '@fxa/payments/customer';
|
||||
import { SubPlatPaymentMethodType } from '@fxa/payments/customer';
|
||||
|
||||
export type CheckoutEvents = CommonMetrics;
|
||||
export type CheckoutPaymentEvents = CommonMetrics & {
|
||||
@@ -50,7 +48,7 @@ export type SP3RolloutEvent = {
|
||||
export type AuthEvents = {
|
||||
type: 'signin' | 'signout' | 'prompt_none_fail' | 'error';
|
||||
errorMessage?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export type PaymentsEmitterEvents = {
|
||||
checkoutView: CheckoutEvents;
|
||||
@@ -67,4 +65,5 @@ export type PaymentsEmitterEvents = {
|
||||
export type AdditionalMetricsData = {
|
||||
cmsMetricsData: CmsMetricsData;
|
||||
cartMetricsData: CartMetrics;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ export async function retrieveAdditionalMetricsData(
|
||||
cartManager: CartManager,
|
||||
params: Record<string, string | undefined>
|
||||
): Promise<AdditionalMetricsData> {
|
||||
const locale = params['locale'] || 'en';
|
||||
const offeringId = params['offeringId'];
|
||||
const interval = params['interval'];
|
||||
const cartId = params['cartId'];
|
||||
@@ -54,6 +55,7 @@ export async function retrieveAdditionalMetricsData(
|
||||
couponCode: cartData.value.couponCode,
|
||||
currency: cartData.value.currency,
|
||||
stripeCustomerId: cartData.value.stripeCustomerId,
|
||||
taxAddress: cartData.value.taxAddress,
|
||||
}
|
||||
: {
|
||||
uid: '',
|
||||
@@ -61,10 +63,12 @@ export async function retrieveAdditionalMetricsData(
|
||||
couponCode: '',
|
||||
currency: '',
|
||||
stripeCustomerId: '',
|
||||
taxAddress: { countryCode: '', postalCode: '' },
|
||||
};
|
||||
|
||||
return {
|
||||
cmsMetricsData,
|
||||
cartMetricsData,
|
||||
locale,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ const expectedCartMetricsData = {
|
||||
couponCode: mockCart.couponCode,
|
||||
currency: mockCart.currency,
|
||||
stripeCustomerId: mockCart.stripeCustomerId,
|
||||
taxAddress: mockCart.taxAddress,
|
||||
};
|
||||
|
||||
const emptyCmsMetricsData = {
|
||||
@@ -43,6 +44,10 @@ const emptyCartMetricsData = {
|
||||
couponCode: '',
|
||||
currency: '',
|
||||
stripeCustomerId: '',
|
||||
taxAddress: {
|
||||
countryCode: '',
|
||||
postalCode: '',
|
||||
},
|
||||
};
|
||||
|
||||
describe('retrieveAdditionalMetricsData', () => {
|
||||
|
||||
18
libs/payments/experiments/.eslintrc.json
Normal file
18
libs/payments/experiments/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/payments/experiments/.swcrc
Normal file
15
libs/payments/experiments/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2017",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true,
|
||||
"legacyDecorator": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
11
libs/payments/experiments/README.md
Normal file
11
libs/payments/experiments/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# experiments
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build experiments` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx test-unit experiments` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
43
libs/payments/experiments/jest.config.ts
Normal file
43
libs/payments/experiments/jest.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable */
|
||||
import { readFileSync } from 'fs';
|
||||
import { Config } from 'jest';
|
||||
|
||||
// Reading the SWC compilation config and remove the "exclude"
|
||||
// for the test files to be compiled by SWC
|
||||
const { exclude: _, ...swcJestConfig } = JSON.parse(
|
||||
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
|
||||
);
|
||||
|
||||
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
|
||||
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
|
||||
if (swcJestConfig.swcrc === undefined) {
|
||||
swcJestConfig.swcrc = false;
|
||||
}
|
||||
|
||||
// Uncomment if using global setup/teardown files being transformed via swc
|
||||
// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
|
||||
// jest needs EsModule Interop to find the default exported setup/teardown functions
|
||||
// swcJestConfig.module.noInterop = false;
|
||||
|
||||
const config: Config = {
|
||||
displayName: 'payments-experiments',
|
||||
preset: '../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../coverage/libs/payments/experiments',
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
outputDirectory: 'artifacts/tests/payments-experiments',
|
||||
outputName: 'payments-experiments-jest-unit-results.xml',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
libs/payments/experiments/package.json
Normal file
9
libs/payments/experiments/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@fxa/payments/experiments",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "./index.cjs",
|
||||
"types": "./index.d.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
29
libs/payments/experiments/project.json
Normal file
29
libs/payments/experiments/project.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "payments-experiments",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/payments/experiments/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/esbuild:esbuild",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/payments/experiments",
|
||||
"main": "libs/payments/experiments/src/index.ts",
|
||||
"tsConfig": "libs/payments/experiments/tsconfig.lib.json",
|
||||
"assets": ["libs/payments/experiments/*.md"],
|
||||
"declaration": true,
|
||||
"generatePackageJson": true
|
||||
}
|
||||
},
|
||||
"test-unit": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/payments/experiments/jest.config.ts",
|
||||
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
libs/payments/experiments/src/index.ts
Normal file
8
libs/payments/experiments/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export * from './lib/nimbus.factories';
|
||||
export * from './lib/nimbus.manager';
|
||||
export * from './lib/nimbus.manager.config';
|
||||
export * from './lib/nimbus.types';
|
||||
30
libs/payments/experiments/src/lib/nimbus.factories.ts
Normal file
30
libs/payments/experiments/src/lib/nimbus.factories.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type {
|
||||
WelcomeFeature,
|
||||
Features,
|
||||
SubPlatNimbusResult,
|
||||
} from './nimbus.types';
|
||||
|
||||
export const WelcomeFeatureFactory = (
|
||||
override?: Partial<WelcomeFeature>
|
||||
): WelcomeFeature => ({
|
||||
enabled: faker.datatype.boolean(),
|
||||
...override,
|
||||
});
|
||||
|
||||
export const FeaturesFactory = (override?: Partial<Features>): Features => ({
|
||||
'welcome-feature': WelcomeFeatureFactory(),
|
||||
...override,
|
||||
});
|
||||
|
||||
export const SubPlatNimbusResultFactory = (
|
||||
override?: Partial<SubPlatNimbusResult>
|
||||
): SubPlatNimbusResult => ({
|
||||
Features: FeaturesFactory(),
|
||||
Enrollments: [],
|
||||
...override,
|
||||
});
|
||||
25
libs/payments/experiments/src/lib/nimbus.manager.config.ts
Normal file
25
libs/payments/experiments/src/lib/nimbus.manager.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { IsBoolean, IsString } from 'class-validator';
|
||||
|
||||
export class NimbusManagerConfig {
|
||||
@IsBoolean()
|
||||
public readonly enabled!: boolean;
|
||||
|
||||
@IsString()
|
||||
public readonly namespace!: string;
|
||||
}
|
||||
|
||||
export const MockNimbusManagerConfig = {
|
||||
enabled: faker.datatype.boolean(),
|
||||
namespace: faker.string.uuid(),
|
||||
} satisfies NimbusManagerConfig;
|
||||
|
||||
export const MockNimbusManagerConfigProvider = {
|
||||
provide: NimbusManagerConfig,
|
||||
useValue: MockNimbusManagerConfig,
|
||||
} satisfies Provider<NimbusManagerConfig>;
|
||||
156
libs/payments/experiments/src/lib/nimbus.manager.spec.ts
Normal file
156
libs/payments/experiments/src/lib/nimbus.manager.spec.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import {
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusEnrollmentFactory,
|
||||
NimbusContextFactory,
|
||||
generateNimbusId,
|
||||
} from '@fxa/shared/experiments';
|
||||
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
|
||||
import { NimbusManager } from './nimbus.manager';
|
||||
import { SubPlatNimbusResultFactory } from './nimbus.factories';
|
||||
import { faker } from '@faker-js/faker/.';
|
||||
import {
|
||||
MockNimbusManagerConfig,
|
||||
NimbusManagerConfig,
|
||||
} from './nimbus.manager.config';
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
jest.mock('@fxa/shared/experiments', () => {
|
||||
const originalModule = jest.requireActual('@fxa/shared/experiments');
|
||||
|
||||
//Mock the default export and named export 'foo'
|
||||
return {
|
||||
__esModule: true,
|
||||
...originalModule,
|
||||
generateNimbusId: jest.fn(),
|
||||
};
|
||||
});
|
||||
const mockedGenerateNimbusId = jest.mocked(generateNimbusId);
|
||||
|
||||
describe('NimbusClient', () => {
|
||||
let nimbusClient: NimbusClient;
|
||||
let nimbusManager: NimbusManager;
|
||||
|
||||
const mockNimbusManagerConfig = MockNimbusManagerConfig;
|
||||
|
||||
beforeEach(async () => {
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
MockStatsDProvider,
|
||||
{
|
||||
provide: NimbusManagerConfig,
|
||||
useValue: mockNimbusManagerConfig,
|
||||
},
|
||||
NimbusManager,
|
||||
{
|
||||
provide: Logger,
|
||||
useValue: {
|
||||
error: jest.fn(),
|
||||
log: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
nimbusClient = module.get(NimbusClient);
|
||||
nimbusManager = module.get(NimbusManager);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchExperiments', () => {
|
||||
const mockContext = NimbusContextFactory();
|
||||
const mockEnrollment = NimbusEnrollmentFactory();
|
||||
const mockFetchExperimentsResult = SubPlatNimbusResultFactory({
|
||||
Enrollments: [mockEnrollment],
|
||||
});
|
||||
const nimbusUserId = mockEnrollment.nimbus_user_id;
|
||||
const mockFetchExperimentsParams = {
|
||||
nimbusUserId,
|
||||
language: mockContext.language || undefined,
|
||||
region: mockContext.region || undefined,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockNimbusManagerConfig.enabled = true;
|
||||
jest
|
||||
.spyOn(nimbusClient, 'fetchExperiments')
|
||||
.mockResolvedValue(mockFetchExperimentsResult);
|
||||
});
|
||||
|
||||
it('successfully returns experiments data', async () => {
|
||||
const result = await nimbusManager.fetchExperiments(
|
||||
mockFetchExperimentsParams
|
||||
);
|
||||
expect(result).toEqual(mockFetchExperimentsResult);
|
||||
expect(nimbusClient.fetchExperiments).toHaveBeenCalledWith({
|
||||
clientId: nimbusUserId,
|
||||
context: mockContext,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null if not enabled', async () => {
|
||||
mockNimbusManagerConfig.enabled = false;
|
||||
const result = await nimbusManager.fetchExperiments(
|
||||
mockFetchExperimentsParams
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('throws an error', async () => {
|
||||
const expectedError = new Error('unexpected error');
|
||||
jest
|
||||
.spyOn(nimbusClient, 'fetchExperiments')
|
||||
.mockRejectedValue(expectedError);
|
||||
await expect(
|
||||
nimbusManager.fetchExperiments(mockFetchExperimentsParams)
|
||||
).rejects.toThrow(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateNimbusId', () => {
|
||||
const mockFxaUid = faker.string.uuid();
|
||||
const mockNimbusUserId = faker.string.uuid();
|
||||
const mockHeaderExperimentId = faker.string.uuid();
|
||||
beforeEach(() => {
|
||||
mockedGenerateNimbusId.mockReturnValue(mockNimbusUserId);
|
||||
});
|
||||
|
||||
it('successfully returns nimbus user id using fxaUid', () => {
|
||||
const result = nimbusManager.generateNimbusId(mockFxaUid);
|
||||
expect(result).toEqual(mockNimbusUserId);
|
||||
expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
|
||||
mockNimbusManagerConfig.namespace,
|
||||
mockFxaUid
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully returns nimbus user id using header experiment id', () => {
|
||||
const result = nimbusManager.generateNimbusId(
|
||||
undefined,
|
||||
mockHeaderExperimentId
|
||||
);
|
||||
expect(result).toEqual(mockHeaderExperimentId);
|
||||
expect(mockedGenerateNimbusId).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('successfully returns nimbus user id newly generated', () => {
|
||||
const result = nimbusManager.generateNimbusId();
|
||||
expect(result).toEqual(mockNimbusUserId);
|
||||
expect(mockedGenerateNimbusId).toHaveBeenCalledWith(
|
||||
mockNimbusManagerConfig.namespace
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
52
libs/payments/experiments/src/lib/nimbus.manager.ts
Normal file
52
libs/payments/experiments/src/lib/nimbus.manager.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { generateNimbusId, NimbusClient } from '@fxa/shared/experiments';
|
||||
import { NimbusManagerConfig } from './nimbus.manager.config';
|
||||
import { SubPlatNimbusResult } from './nimbus.types';
|
||||
|
||||
@Injectable()
|
||||
export class NimbusManager {
|
||||
constructor(
|
||||
private log: Logger,
|
||||
private nimbusClient: NimbusClient,
|
||||
private nimbusManagerConfig: NimbusManagerConfig
|
||||
) {}
|
||||
|
||||
async fetchExperiments({
|
||||
nimbusUserId,
|
||||
language,
|
||||
region,
|
||||
}: {
|
||||
nimbusUserId: string;
|
||||
language?: string;
|
||||
region?: string;
|
||||
}) {
|
||||
if (!this.nimbusManagerConfig.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const results =
|
||||
await this.nimbusClient.fetchExperiments<SubPlatNimbusResult>({
|
||||
clientId: nimbusUserId,
|
||||
context: { language: language || null, region: region || null },
|
||||
});
|
||||
|
||||
// Temporarily log results for debugging purposes
|
||||
this.log.log(JSON.stringify(results));
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
generateNimbusId(fxaUid?: string, headerExperimentId?: string) {
|
||||
if (fxaUid) {
|
||||
return generateNimbusId(this.nimbusManagerConfig.namespace, fxaUid);
|
||||
} else if (headerExperimentId) {
|
||||
return headerExperimentId;
|
||||
} else {
|
||||
return generateNimbusId(this.nimbusManagerConfig.namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
17
libs/payments/experiments/src/lib/nimbus.types.ts
Normal file
17
libs/payments/experiments/src/lib/nimbus.types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import type { NimbusResult } from '@fxa/shared/experiments';
|
||||
|
||||
export interface WelcomeFeature {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
'welcome-feature': WelcomeFeature;
|
||||
}
|
||||
|
||||
export interface SubPlatNimbusResult extends NimbusResult {
|
||||
Features: Features;
|
||||
}
|
||||
23
libs/payments/experiments/tsconfig.json
Normal file
23
libs/payments/experiments/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/payments/experiments/tsconfig.lib.json
Normal file
10
libs/payments/experiments/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
15
libs/payments/experiments/tsconfig.spec.json
Normal file
15
libs/payments/experiments/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CmsMetricsData,
|
||||
CommonMetrics,
|
||||
SubscriptionCancellationData,
|
||||
type ExperimentationData,
|
||||
} from './glean.types';
|
||||
import { ResultCartFactory } from '@fxa/payments/cart';
|
||||
import { SubplatInterval } from '@fxa/payments/customer';
|
||||
@@ -36,6 +37,7 @@ export const CommonMetricsFactory = (
|
||||
userAgent: faker.internet.userAgent(),
|
||||
params: {},
|
||||
searchParams: {},
|
||||
experimentationId: faker.string.uuid(),
|
||||
...override,
|
||||
});
|
||||
|
||||
@@ -52,6 +54,7 @@ export const CartMetricsFactory = (
|
||||
couponCode: resultCart.couponCode,
|
||||
currency: faker.finance.currencyCode().toLowerCase(),
|
||||
stripeCustomerId: `cus_${faker.string.alphanumeric({ length: 14 })}`,
|
||||
taxAddress: resultCart.taxAddress,
|
||||
...override,
|
||||
};
|
||||
};
|
||||
@@ -81,3 +84,10 @@ export const SubscriptionCancellationDataFactory = (
|
||||
...override,
|
||||
};
|
||||
};
|
||||
|
||||
export const ExperimentationDataFactory = (
|
||||
override?: Partial<ExperimentationData>
|
||||
): ExperimentationData => ({
|
||||
nimbusUserId: faker.string.uuid(),
|
||||
...override,
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
PaymentProvidersType,
|
||||
PaymentsGleanProvider,
|
||||
SubscriptionCancellationData,
|
||||
type ExperimentationData,
|
||||
} from './glean.types';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { type PaymentsGleanServerEventsLogger } from './glean.provider';
|
||||
@@ -34,6 +35,7 @@ export class PaymentsGleanManager {
|
||||
commonMetricsData: CommonMetrics;
|
||||
cartMetricsData: CartMetrics;
|
||||
cmsMetricsData: CmsMetricsData;
|
||||
experimentationData: ExperimentationData;
|
||||
}) {
|
||||
if (this.isEnabled) {
|
||||
this.paymentsGleanServerEventsLogger.recordPaySetupView(
|
||||
@@ -131,12 +133,14 @@ export class PaymentsGleanManager {
|
||||
commonMetricsData?: CommonMetrics;
|
||||
cartMetricsData?: CartMetrics;
|
||||
cmsMetricsData?: CmsMetricsData;
|
||||
experimentationData?: ExperimentationData;
|
||||
subscriptionCancellationData?: SubscriptionCancellationData;
|
||||
}) {
|
||||
const emptyCommonMetricsData: CommonMetrics = {
|
||||
ipAddress: '',
|
||||
deviceType: '',
|
||||
userAgent: '',
|
||||
experimentationId: '',
|
||||
params: {},
|
||||
searchParams: {},
|
||||
};
|
||||
@@ -146,6 +150,7 @@ export class PaymentsGleanManager {
|
||||
errorReasonId: null,
|
||||
couponCode: '',
|
||||
currency: '',
|
||||
taxAddress: { countryCode: '', postalCode: '' },
|
||||
};
|
||||
const emptyCmsMetricsData: CmsMetricsData = {
|
||||
priceId: '',
|
||||
@@ -157,12 +162,17 @@ export class PaymentsGleanManager {
|
||||
cancellationReason: CancellationReason.Involuntary,
|
||||
providerEventId: '',
|
||||
};
|
||||
const emptyExperimentationData: ExperimentationData = {
|
||||
nimbusUserId: '',
|
||||
};
|
||||
const commonMetricsData =
|
||||
metrics.commonMetricsData || emptyCommonMetricsData;
|
||||
const cartMetricsData = metrics.cartMetricsData || emptyCartMetricsData;
|
||||
const cmsMetricsData = metrics.cmsMetricsData || emptyCmsMetricsData;
|
||||
const subscriptionCancellationData =
|
||||
metrics.subscriptionCancellationData || emptySubscriptionCancellationData;
|
||||
const experimentationData =
|
||||
metrics.experimentationData || emptyExperimentationData;
|
||||
return {
|
||||
user_agent: commonMetricsData.userAgent,
|
||||
ip_address: commonMetricsData.ipAddress,
|
||||
@@ -178,7 +188,7 @@ export class PaymentsGleanManager {
|
||||
}),
|
||||
...mapUtm(commonMetricsData.searchParams),
|
||||
...mapSubscriptionCancellation(subscriptionCancellationData),
|
||||
nimbus_user_id: '',
|
||||
nimbus_user_id: experimentationData.nimbusUserId,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -26,15 +26,25 @@ export type CommonMetrics = {
|
||||
ipAddress: string;
|
||||
deviceType: string;
|
||||
userAgent: string;
|
||||
experimentationId: string;
|
||||
params: Record<string, string>;
|
||||
searchParams: Record<string, string>;
|
||||
};
|
||||
|
||||
export type CartMetrics = Pick<
|
||||
ResultCart,
|
||||
'uid' | 'errorReasonId' | 'couponCode' | 'currency' | 'stripeCustomerId'
|
||||
| 'uid'
|
||||
| 'errorReasonId'
|
||||
| 'couponCode'
|
||||
| 'currency'
|
||||
| 'stripeCustomerId'
|
||||
| 'taxAddress'
|
||||
>;
|
||||
|
||||
export type ExperimentationData = {
|
||||
nimbusUserId: string;
|
||||
};
|
||||
|
||||
export type CmsMetricsData = {
|
||||
productId: string;
|
||||
priceId: string;
|
||||
|
||||
31
libs/payments/ui/src/lib/actions/getExperimentsAction.ts
Normal file
31
libs/payments/ui/src/lib/actions/getExperimentsAction.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
'use server';
|
||||
|
||||
import { getApp } from '../nestapp/app';
|
||||
import { getIpAddress } from '../utils/getIpAddress';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
export const getExperimentsAction = async (args: {
|
||||
language: string;
|
||||
fxaUid?: string;
|
||||
}) => {
|
||||
const ip = getIpAddress();
|
||||
const experimentationId = headers().get('x-experimentation-id') || '';
|
||||
|
||||
try {
|
||||
const experiments = await getApp()
|
||||
.getActionsService()
|
||||
.getExperiments({
|
||||
...args,
|
||||
ip,
|
||||
experimentationId,
|
||||
});
|
||||
|
||||
return experiments?.experiments;
|
||||
} catch (error) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
@@ -35,3 +35,4 @@ export { serverLogAction } from './serverLog';
|
||||
export { getStripeClientSession } from './getStripeClientSession';
|
||||
export { updateStripePaymentDetails } from './updateStripePaymentDetails';
|
||||
export { setDefaultStripePaymentDetails } from './setDefaultStripePaymentDetails';
|
||||
export { getExperimentsAction } from './getExperimentsAction';
|
||||
|
||||
@@ -69,6 +69,8 @@ import {
|
||||
GoogleIapClient,
|
||||
GoogleIapPurchaseManager,
|
||||
} from '@fxa/payments/iap';
|
||||
import { NimbusClient } from '@fxa/shared/experiments';
|
||||
import { NimbusManager } from '@fxa/payments/experiments';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -144,6 +146,8 @@ import {
|
||||
SubscriptionEventsService,
|
||||
StripeWebhookService,
|
||||
SubscriptionManagementService,
|
||||
NimbusClient,
|
||||
NimbusManager,
|
||||
{ provide: LOGGER_PROVIDER, useValue: logger },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -22,6 +22,8 @@ import { AppleIapClientConfig, GoogleIapClientConfig } from '@fxa/payments/iap';
|
||||
import { TracingConfig } from './tracing.config';
|
||||
import { StripeEventConfig } from '@fxa/payments/webhooks';
|
||||
import { FirestoreConfig } from 'libs/shared/db/firestore/src/lib/firestore.config';
|
||||
import { NimbusClientConfig } from 'libs/shared/experiments/src/lib/nimbus.config';
|
||||
import { NimbusManagerConfig } from '@fxa/payments/experiments';
|
||||
|
||||
export class RootConfig {
|
||||
@Type(() => MySQLConfig)
|
||||
@@ -117,4 +119,14 @@ export class RootConfig {
|
||||
@ValidateNested()
|
||||
@IsDefined()
|
||||
location!: LocationConfig;
|
||||
|
||||
@Type(() => NimbusClientConfig)
|
||||
@ValidateNested()
|
||||
@IsDefined()
|
||||
nimbusClient!: NimbusClientConfig;
|
||||
|
||||
@Type(() => NimbusManagerConfig)
|
||||
@ValidateNested()
|
||||
@IsDefined()
|
||||
nimbusManager!: NimbusManagerConfig;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import { SubscriptionManagementService } from '@fxa/payments/management';
|
||||
import {
|
||||
CheckoutTokenManager,
|
||||
PaypalBillingAgreementManager,
|
||||
PaypalCustomerManager,
|
||||
} from '@fxa/payments/paypal';
|
||||
import {
|
||||
ProductConfigError,
|
||||
@@ -109,6 +108,9 @@ import { GetPaypalBillingAgreementActiveIdResult } from './validators/GetPaypalB
|
||||
import { CreatePaypalBillingAgreementIdArgs } from './validators/CreatePaypalBillingAgreementIdArgs';
|
||||
import { DetermineCurrencyForCustomerActionArgs } from './validators/DetermineCurrencyForCustomerActionArgs';
|
||||
import { DetermineCurrencyForCustomerActionResult } from './validators/DetermineCurrencyForCustomerActionResult';
|
||||
import { NimbusManager } from '@fxa/payments/experiments';
|
||||
import { GetExperimentsActionArgs } from './validators/GetExperimentsActionArgs';
|
||||
import { GetExperimentsActionResult } from './validators/GetExperimentsActionResult';
|
||||
|
||||
/**
|
||||
* ANY AND ALL methods exposed via this service should be considered publicly accessible and callable with any arguments.
|
||||
@@ -131,7 +133,7 @@ export class NextJSActionsService {
|
||||
private profileClient: ProfileClient,
|
||||
private subscriptionManagementService: SubscriptionManagementService,
|
||||
private paypalBillingAgreementManager: PaypalBillingAgreementManager,
|
||||
private paypalCustomerManager: PaypalCustomerManager,
|
||||
private nimbusManager: NimbusManager,
|
||||
@Inject(StatsDService) public statsd: StatsD,
|
||||
@Inject(Logger) private log: LoggerService
|
||||
) {}
|
||||
@@ -181,6 +183,36 @@ export class NextJSActionsService {
|
||||
return this.cartService.getCart(args.cartId);
|
||||
}
|
||||
|
||||
@SanitizeExceptions()
|
||||
@NextIOValidator(GetExperimentsActionArgs, GetExperimentsActionResult)
|
||||
@WithTypeCachableAsyncLocalStorage()
|
||||
@CaptureTimingWithStatsD()
|
||||
async getExperiments(args: {
|
||||
language: string;
|
||||
ip: string;
|
||||
fxaUid?: string;
|
||||
experimentationId?: string;
|
||||
}) {
|
||||
const nimbusUserId = this.nimbusManager.generateNimbusId(
|
||||
args.fxaUid,
|
||||
args.experimentationId
|
||||
);
|
||||
const location = this.geodbManager.getTaxAddress(args.ip);
|
||||
const experiments = await this.nimbusManager.fetchExperiments({
|
||||
nimbusUserId,
|
||||
language: args.language,
|
||||
region: location?.countryCode,
|
||||
});
|
||||
|
||||
if (experiments) {
|
||||
return {
|
||||
experiments,
|
||||
};
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@SanitizeExceptions({
|
||||
allowlist: [CartInvalidStateForActionError],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class GetExperimentsActionArgs {
|
||||
@IsString()
|
||||
language!: string;
|
||||
|
||||
@IsString()
|
||||
ip!: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
experimentationId?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
fxaUid?: string;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import type { Features } from '@fxa/payments/experiments';
|
||||
import type { NimbusEnrollment } from '@fxa/shared/experiments';
|
||||
import { IsArray, IsDefined } from 'class-validator';
|
||||
|
||||
class Experiments {
|
||||
@IsDefined()
|
||||
Features!: Features;
|
||||
|
||||
@IsArray()
|
||||
Enrollments!: Array<NimbusEnrollment>;
|
||||
}
|
||||
|
||||
export class GetExperimentsActionResult {
|
||||
experiments?: Experiments;
|
||||
}
|
||||
@@ -30,6 +30,9 @@ class RequestArgs {
|
||||
@IsString()
|
||||
userAgent!: string;
|
||||
|
||||
@IsString()
|
||||
experimentationId!: string;
|
||||
|
||||
@IsObject()
|
||||
params!: Record<string, string>;
|
||||
|
||||
|
||||
@@ -9,9 +9,12 @@ import { getIpAddress } from './getIpAddress';
|
||||
export function getAdditionalRequestArgs() {
|
||||
const userAgentString = headers().get('user-agent') || '';
|
||||
const userAgent = userAgentFromString(userAgentString);
|
||||
const experimentationId = headers().get('x-experimentation-id') || '';
|
||||
|
||||
return {
|
||||
ipAddress: getIpAddress(),
|
||||
userAgent: userAgentString,
|
||||
deviceType: userAgent.device.type || 'desktop',
|
||||
experimentationId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -56,6 +56,14 @@ import {
|
||||
StripeEventStoreEntryAlreadyExistsError,
|
||||
StripeEventStoreEntryNotFoundError,
|
||||
} from './stripe-event-store.error';
|
||||
import {
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusClient,
|
||||
} from '@fxa/shared/experiments';
|
||||
import {
|
||||
MockNimbusManagerConfigProvider,
|
||||
NimbusManager,
|
||||
} from '@fxa/payments/experiments';
|
||||
|
||||
jest.mock('./stripe-event-store.repository');
|
||||
const mockedGetStripeEventStoreEntry = jest.mocked(getStripeEventStoreEntry);
|
||||
@@ -115,6 +123,10 @@ describe('StripeEventManager', () => {
|
||||
MockStatsDProvider,
|
||||
PriceManager,
|
||||
MockAccountDatabaseNestFactory,
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusManager,
|
||||
MockNimbusManagerConfigProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -41,6 +41,14 @@ import { MockFirestoreProvider } from '@fxa/shared/db/firestore';
|
||||
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
|
||||
import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusClient,
|
||||
} from '@fxa/shared/experiments';
|
||||
import {
|
||||
MockNimbusManagerConfigProvider,
|
||||
NimbusManager,
|
||||
} from '@fxa/payments/experiments';
|
||||
|
||||
describe('StripeWebhooksController', () => {
|
||||
let stripeWebhooksController: StripeWebhooksController;
|
||||
@@ -96,6 +104,10 @@ describe('StripeWebhooksController', () => {
|
||||
StripeWebhookService,
|
||||
StripeEventManager,
|
||||
SubscriptionEventsService,
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusManager,
|
||||
MockNimbusManagerConfigProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -43,6 +43,14 @@ import { MockAccountDatabaseNestFactory } from '@fxa/shared/db/mysql/account';
|
||||
import * as Sentry from '@sentry/node';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MockStripeEventConfigProvider } from './stripe-event.config';
|
||||
import {
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusClient,
|
||||
} from '@fxa/shared/experiments';
|
||||
import {
|
||||
MockNimbusManagerConfigProvider,
|
||||
NimbusManager,
|
||||
} from '@fxa/payments/experiments';
|
||||
|
||||
jest.mock('@sentry/node', () => ({
|
||||
captureException: jest.fn(),
|
||||
@@ -99,6 +107,10 @@ describe('StripeWebhookService', () => {
|
||||
MockStatsDProvider,
|
||||
PriceManager,
|
||||
MockAccountDatabaseNestFactory,
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusManager,
|
||||
MockNimbusManagerConfigProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -55,6 +55,14 @@ import {
|
||||
} from './util/determineCancellation';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { MockStripeEventConfigProvider } from './stripe-event.config';
|
||||
import {
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusClient,
|
||||
} from '@fxa/shared/experiments';
|
||||
import {
|
||||
MockNimbusManagerConfigProvider,
|
||||
NimbusManager,
|
||||
} from '@fxa/payments/experiments';
|
||||
|
||||
jest.mock('@fxa/payments/customer');
|
||||
jest.mock('./util/determineCancellation');
|
||||
@@ -120,6 +128,10 @@ describe('SubscriptionEventsService', () => {
|
||||
MockStatsDProvider,
|
||||
PriceManager,
|
||||
MockAccountDatabaseNestFactory,
|
||||
NimbusClient,
|
||||
MockNimbusClientConfigProvider,
|
||||
NimbusManager,
|
||||
MockNimbusManagerConfigProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
18
libs/shared/experiments/.eslintrc.json
Normal file
18
libs/shared/experiments/.eslintrc.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": ["../../../.eslintrc.json"],
|
||||
"ignorePatterns": ["!**/*"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.ts", "*.tsx"],
|
||||
"rules": {}
|
||||
},
|
||||
{
|
||||
"files": ["*.js", "*.jsx"],
|
||||
"rules": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/shared/experiments/.swcrc
Normal file
15
libs/shared/experiments/.swcrc
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"jsc": {
|
||||
"target": "es2017",
|
||||
"parser": {
|
||||
"syntax": "typescript",
|
||||
"decorators": true,
|
||||
"dynamicImport": true
|
||||
},
|
||||
"transform": {
|
||||
"decoratorMetadata": true,
|
||||
"legacyDecorator": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
libs/shared/experiments/README.md
Normal file
12
libs/shared/experiments/README.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# shared-experiments
|
||||
|
||||
This library was generated with [Nx](https://nx.dev).
|
||||
|
||||
## Building
|
||||
|
||||
Run `nx build shared-experiments` to build the library.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
Run `nx run shared-experiments:test-unit` to execute the unit tests via [Jest](https://jestjs.io).
|
||||
|
||||
43
libs/shared/experiments/jest.config.ts
Normal file
43
libs/shared/experiments/jest.config.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/* eslint-disable */
|
||||
import { readFileSync } from 'fs';
|
||||
import { Config } from 'jest';
|
||||
|
||||
// Reading the SWC compilation config and remove the "exclude"
|
||||
// for the test files to be compiled by SWC
|
||||
const { exclude: _, ...swcJestConfig } = JSON.parse(
|
||||
readFileSync(`${__dirname}/.swcrc`, 'utf-8')
|
||||
);
|
||||
|
||||
// disable .swcrc look-up by SWC core because we're passing in swcJestConfig ourselves.
|
||||
// If we do not disable this, SWC Core will read .swcrc and won't transform our test files due to "exclude"
|
||||
if (swcJestConfig.swcrc === undefined) {
|
||||
swcJestConfig.swcrc = false;
|
||||
}
|
||||
|
||||
// Uncomment if using global setup/teardown files being transformed via swc
|
||||
// https://nx.dev/packages/jest/documents/overview#global-setup/teardown-with-nx-libraries
|
||||
// jest needs EsModule Interop to find the default exported setup/teardown functions
|
||||
// swcJestConfig.module.noInterop = false;
|
||||
|
||||
const config: Config = {
|
||||
displayName: 'shared-experiments',
|
||||
preset: '../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['@swc/jest', swcJestConfig],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../coverage/libs/shared/experiments',
|
||||
reporters: [
|
||||
'default',
|
||||
[
|
||||
'jest-junit',
|
||||
{
|
||||
outputDirectory: 'artifacts/tests/shared-experiments',
|
||||
outputName: 'shared-experiments-jest-unit-results.xml',
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
9
libs/shared/experiments/package.json
Normal file
9
libs/shared/experiments/package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "@fxa/shared/experiments",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
"main": "./index.cjs",
|
||||
"types": "./index.d.ts",
|
||||
"dependencies": {}
|
||||
}
|
||||
29
libs/shared/experiments/project.json
Normal file
29
libs/shared/experiments/project.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "shared-experiments",
|
||||
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/shared/experiments/src",
|
||||
"projectType": "library",
|
||||
"tags": [],
|
||||
"targets": {
|
||||
"build": {
|
||||
"executor": "@nx/esbuild:esbuild",
|
||||
"outputs": ["{options.outputPath}"],
|
||||
"options": {
|
||||
"outputPath": "dist/libs/shared/experiments",
|
||||
"main": "libs/shared/experiments/src/index.ts",
|
||||
"tsConfig": "libs/shared/experiments/tsconfig.lib.json",
|
||||
"assets": ["libs/shared/experiments/*.md"],
|
||||
"declaration": true,
|
||||
"generatePackageJson": true
|
||||
}
|
||||
},
|
||||
"test-unit": {
|
||||
"executor": "@nx/jest:jest",
|
||||
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
|
||||
"options": {
|
||||
"jestConfig": "libs/shared/experiments/jest.config.ts",
|
||||
"testPathPattern": ["^(?!.*\\.in\\.spec\\.ts$).*$"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
libs/shared/experiments/src/index.ts
Normal file
6
libs/shared/experiments/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './lib/nimbus.config';
|
||||
export * from './lib/nimbus.client';
|
||||
export * from './lib/nimbus.errors';
|
||||
export * from './lib/nimbus.types';
|
||||
export * from './lib/nimbus.factories';
|
||||
export * from './lib/utils/generateNimbusId';
|
||||
124
libs/shared/experiments/src/lib/nimbus.client.spec.ts
Normal file
124
libs/shared/experiments/src/lib/nimbus.client.spec.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { NimbusClient } from './nimbus.client';
|
||||
import { MockNimbusClientConfig, NimbusClientConfig } from './nimbus.config';
|
||||
import { MockStatsDProvider } from '@fxa/shared/metrics/statsd';
|
||||
import {
|
||||
NimbusContextFactory,
|
||||
NimbusEnrollmentFactory,
|
||||
NimbusResultFactory,
|
||||
} from './nimbus.factories';
|
||||
import {
|
||||
NimbusClientFetchExperimentsHandledError,
|
||||
NimbusClientFetchExperimentsUnexpectedError,
|
||||
} from './nimbus.errors';
|
||||
|
||||
describe('NimbusClient', () => {
|
||||
let nimbusClient: NimbusClient;
|
||||
|
||||
const mockNimbusClientConfig = MockNimbusClientConfig;
|
||||
const mockContext = NimbusContextFactory();
|
||||
const mockEnrollment = NimbusEnrollmentFactory();
|
||||
const mockResult = NimbusResultFactory({
|
||||
Enrollments: [mockEnrollment],
|
||||
});
|
||||
const nimbusUserId = mockEnrollment.nimbus_user_id;
|
||||
const mockFetchExperimentsParams = {
|
||||
clientId: nimbusUserId,
|
||||
context: mockContext,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
NimbusClient,
|
||||
{
|
||||
provide: NimbusClientConfig,
|
||||
useValue: mockNimbusClientConfig,
|
||||
},
|
||||
MockStatsDProvider,
|
||||
],
|
||||
}).compile();
|
||||
|
||||
nimbusClient = module.get(NimbusClient);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('fetchExperiments', () => {
|
||||
beforeEach(() => {
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
json: () => Promise.resolve(mockResult),
|
||||
ok: true,
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
|
||||
it('successfully returns experiments from Nimbus', async () => {
|
||||
const result = await nimbusClient.fetchExperiments(
|
||||
mockFetchExperimentsParams
|
||||
);
|
||||
expect(result).toEqual(mockResult);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
body: JSON.stringify({
|
||||
client_id: nimbusUserId,
|
||||
context: mockContext,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('successfully calls with preview enabled', async () => {
|
||||
mockNimbusClientConfig.previewEnabled = true;
|
||||
const expectedUrl =
|
||||
mockNimbusClientConfig.apiUrl + '?nimbus_preview=true';
|
||||
await nimbusClient.fetchExperiments(mockFetchExperimentsParams);
|
||||
expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());
|
||||
});
|
||||
|
||||
it('successfully calls with preview disabled', async () => {
|
||||
mockNimbusClientConfig.previewEnabled = false;
|
||||
const expectedUrl =
|
||||
mockNimbusClientConfig.apiUrl + '?nimbus_preview=false';
|
||||
await nimbusClient.fetchExperiments(mockFetchExperimentsParams);
|
||||
expect(fetch).toHaveBeenCalledWith(expectedUrl, expect.anything());
|
||||
});
|
||||
|
||||
it('throws a handled error', async () => {
|
||||
const errorData = { message: 'Handled error' };
|
||||
const expectedError = new NimbusClientFetchExperimentsHandledError(
|
||||
errorData,
|
||||
mockFetchExperimentsParams
|
||||
);
|
||||
(fetch as jest.Mock).mockResolvedValue({
|
||||
json: () => Promise.resolve(errorData),
|
||||
ok: false,
|
||||
status: 400,
|
||||
});
|
||||
await expect(
|
||||
nimbusClient.fetchExperiments(mockFetchExperimentsParams)
|
||||
).rejects.toThrow(expectedError);
|
||||
});
|
||||
|
||||
it('throws unhandled error', async () => {
|
||||
const unexpectedError = new Error('unexpected error');
|
||||
const expectedError = new NimbusClientFetchExperimentsUnexpectedError(
|
||||
unexpectedError,
|
||||
mockFetchExperimentsParams
|
||||
);
|
||||
(fetch as jest.Mock).mockRejectedValue(unexpectedError);
|
||||
await expect(
|
||||
nimbusClient.fetchExperiments(mockFetchExperimentsParams)
|
||||
).rejects.toThrow(expectedError);
|
||||
});
|
||||
});
|
||||
});
|
||||
80
libs/shared/experiments/src/lib/nimbus.client.ts
Normal file
80
libs/shared/experiments/src/lib/nimbus.client.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { NimbusClientConfig } from './nimbus.config';
|
||||
import type { NimbusContext } from './nimbus.types';
|
||||
import {
|
||||
NimbusClientFetchExperimentsUnexpectedError,
|
||||
NimbusClientFetchExperimentsHandledError,
|
||||
} from './nimbus.errors';
|
||||
import {
|
||||
CaptureTimingWithStatsD,
|
||||
StatsD,
|
||||
StatsDService,
|
||||
} from '@fxa/shared/metrics/statsd';
|
||||
|
||||
@Injectable()
|
||||
export class NimbusClient {
|
||||
constructor(
|
||||
private config: NimbusClientConfig,
|
||||
@Inject(StatsDService) public statsd: StatsD
|
||||
) {}
|
||||
|
||||
@CaptureTimingWithStatsD()
|
||||
async fetchExperiments<ResultT>(params: {
|
||||
clientId: string;
|
||||
context: NimbusContext;
|
||||
}) {
|
||||
const { clientId, context } = params;
|
||||
|
||||
const body = JSON.stringify({
|
||||
client_id: clientId,
|
||||
context,
|
||||
});
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
nimbus_preview: this.config.previewEnabled === true ? 'true' : 'false',
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, this.config.timeoutMs);
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`${this.config.apiUrl}?${queryParams.toString()}`,
|
||||
{
|
||||
method: 'POST',
|
||||
body,
|
||||
// A request to cirrus should not be more than 50ms,
|
||||
// but we give it a large enough padding.
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!resp.ok) {
|
||||
const errorResponse = await resp.json();
|
||||
throw new NimbusClientFetchExperimentsHandledError(
|
||||
errorResponse,
|
||||
params
|
||||
);
|
||||
}
|
||||
|
||||
return (await resp.json()) as ResultT;
|
||||
} catch (err) {
|
||||
if (err instanceof NimbusClientFetchExperimentsHandledError) {
|
||||
throw err;
|
||||
} else {
|
||||
throw new NimbusClientFetchExperimentsUnexpectedError(err, params);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
29
libs/shared/experiments/src/lib/nimbus.config.ts
Normal file
29
libs/shared/experiments/src/lib/nimbus.config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { Provider } from '@nestjs/common';
|
||||
import { IsBoolean, IsNumber, IsUrl } from 'class-validator';
|
||||
|
||||
export class NimbusClientConfig {
|
||||
@IsUrl({ require_tld: false })
|
||||
public readonly apiUrl!: string;
|
||||
|
||||
@IsBoolean()
|
||||
public readonly previewEnabled!: boolean;
|
||||
|
||||
@IsNumber()
|
||||
public readonly timeoutMs!: number;
|
||||
}
|
||||
|
||||
export const MockNimbusClientConfig = {
|
||||
apiUrl: faker.internet.url(),
|
||||
previewEnabled: faker.datatype.boolean(),
|
||||
timeoutMs: faker.number.int({ min: 100, max: 2000 }),
|
||||
} satisfies NimbusClientConfig;
|
||||
|
||||
export const MockNimbusClientConfigProvider = {
|
||||
provide: NimbusClientConfig,
|
||||
useValue: MockNimbusClientConfig,
|
||||
} satisfies Provider<NimbusClientConfig>;
|
||||
46
libs/shared/experiments/src/lib/nimbus.errors.ts
Normal file
46
libs/shared/experiments/src/lib/nimbus.errors.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { BaseError } from '@fxa/shared/error';
|
||||
import type { NimbusContext } from './nimbus.types';
|
||||
|
||||
/**
|
||||
* Nimbus is not intended for direct use, except for type-checking errors.
|
||||
* When throwing a new AppleIapError, create a unique extension of the class.
|
||||
*/
|
||||
export class NimbusError extends BaseError {
|
||||
constructor(message: string, info: Record<string, any>, cause?: Error) {
|
||||
super(message, { info, cause });
|
||||
this.name = 'NimbusError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NimbusClientFetchExperimentsHandledError extends NimbusError {
|
||||
constructor(
|
||||
errorResponse: any,
|
||||
params: {
|
||||
clientId: string;
|
||||
context: NimbusContext;
|
||||
}
|
||||
) {
|
||||
super('Error ocurred while fetching experiments', {
|
||||
errorResponse,
|
||||
params,
|
||||
});
|
||||
this.name = 'NimbusClientFetchExperimentsError';
|
||||
}
|
||||
}
|
||||
|
||||
export class NimbusClientFetchExperimentsUnexpectedError extends NimbusError {
|
||||
constructor(
|
||||
cause: Error,
|
||||
params: {
|
||||
clientId: string;
|
||||
context: NimbusContext;
|
||||
}
|
||||
) {
|
||||
super('Error ocurred while fetching experiments', { params }, cause);
|
||||
this.name = 'NimbusClientFetchExperimentsUnexpectedError';
|
||||
}
|
||||
}
|
||||
38
libs/shared/experiments/src/lib/nimbus.factories.ts
Normal file
38
libs/shared/experiments/src/lib/nimbus.factories.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import type {
|
||||
NimbusContext,
|
||||
NimbusEnrollment,
|
||||
NimbusResult,
|
||||
} from './nimbus.types';
|
||||
|
||||
export const NimbusContextFactory = (
|
||||
override?: Partial<NimbusContext>
|
||||
): NimbusContext => ({
|
||||
language: faker.location.language().alpha2,
|
||||
region: faker.location.countryCode('alpha-2'),
|
||||
...override,
|
||||
});
|
||||
|
||||
export const NimbusEnrollmentFactory = (
|
||||
override?: Partial<NimbusEnrollment>
|
||||
): NimbusEnrollment => ({
|
||||
nimbus_user_id: faker.string.uuid(),
|
||||
app_id: faker.string.uuid(),
|
||||
experiment: faker.lorem.word(),
|
||||
branch: faker.helpers.arrayElement(['develop', 'stage', 'prod']),
|
||||
experiment_type: faker.helpers.arrayElement(['feature', 'abtest']),
|
||||
is_preview: faker.datatype.boolean().toString(),
|
||||
...override,
|
||||
});
|
||||
|
||||
export const NimbusResultFactory = (
|
||||
override?: Partial<NimbusResult>
|
||||
): NimbusResult => ({
|
||||
Features: {},
|
||||
Enrollments: [],
|
||||
...override,
|
||||
});
|
||||
32
libs/shared/experiments/src/lib/nimbus.types.ts
Normal file
32
libs/shared/experiments/src/lib/nimbus.types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
/**
|
||||
* A collection of attributes about the client that will be used for
|
||||
* targeting an experiment.
|
||||
*/
|
||||
export type NimbusContext = {
|
||||
language: string | null;
|
||||
region: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The nimbus experiments and enrollment information needed for applying a feature experiment.
|
||||
*/
|
||||
export interface NimbusEnrollment {
|
||||
nimbus_user_id: string;
|
||||
app_id: string;
|
||||
experiment: string;
|
||||
branch: string;
|
||||
experiment_type: string;
|
||||
is_preview: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The nimbus experiments and enrollment information needed for applying a feature experiment.
|
||||
*/
|
||||
export interface NimbusResult {
|
||||
Features: Record<string, any>;
|
||||
Enrollments: Array<NimbusEnrollment>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { generateNimbusId } from './generateNimbusId';
|
||||
|
||||
describe('generateNimbusId', () => {
|
||||
const namespace = '2b94b5a8-407f-5ff4-8d6a-e53ada958c9d';
|
||||
|
||||
it('returns a uuid v5', () => {
|
||||
const id = 'test-id';
|
||||
const expected = 'a9e87f95-efa5-575e-bac2-e297abb1e597';
|
||||
|
||||
expect(generateNimbusId(namespace, id)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('returns a guest id with random uuid', () => {
|
||||
const result = generateNimbusId(namespace);
|
||||
expect(result.startsWith('guest-')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
13
libs/shared/experiments/src/lib/utils/generateNimbusId.ts
Normal file
13
libs/shared/experiments/src/lib/utils/generateNimbusId.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
export function generateNimbusId(namespace: string, id?: string) {
|
||||
if (id) {
|
||||
return uuidv5(id, namespace);
|
||||
} else {
|
||||
return `guest-${crypto.randomUUID()}`;
|
||||
}
|
||||
}
|
||||
23
libs/shared/experiments/tsconfig.json
Normal file
23
libs/shared/experiments/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
libs/shared/experiments/tsconfig.lib.json
Normal file
10
libs/shared/experiments/tsconfig.lib.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"]
|
||||
}
|
||||
15
libs/shared/experiments/tsconfig.spec.json
Normal file
15
libs/shared/experiments/tsconfig.spec.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node10",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -42,6 +42,7 @@
|
||||
"@fxa/payments/customer": ["libs/payments/customer/src/index.ts"],
|
||||
"@fxa/payments/eligibility": ["libs/payments/eligibility/src/index.ts"],
|
||||
"@fxa/payments/events": ["libs/payments/events/src/index.ts"],
|
||||
"@fxa/payments/experiments": ["libs/payments/experiments/src/index.ts"],
|
||||
"@fxa/payments/iap": ["libs/payments/iap/src/index.ts"],
|
||||
"@fxa/payments/legacy": ["libs/payments/legacy/src/index.ts"],
|
||||
"@fxa/payments/management": ["libs/payments/management/src/index.ts"],
|
||||
@@ -76,6 +77,7 @@
|
||||
],
|
||||
"@fxa/shared/error": ["libs/shared/error/src/index.ts"],
|
||||
"@fxa/shared/error/error": ["libs/shared/error/src/lib/error.ts"],
|
||||
"@fxa/shared/experiments": ["libs/shared/experiments/src/index.ts"],
|
||||
"@fxa/shared/geodb": ["libs/shared/geodb/src/index.ts"],
|
||||
"@fxa/shared/glean": ["libs/shared/metrics/glean/src/index.ts"],
|
||||
"@fxa/shared/guards": ["libs/shared/guards/src/index.ts"],
|
||||
|
||||
Reference in New Issue
Block a user