Merge pull request #19616 from mozilla/pay-3250-add-nimbus

feat(next): add experiments to payments-next
This commit is contained in:
Reino Muhl
2025-11-06 14:06:21 -05:00
committed by GitHub
61 changed files with 1701 additions and 354 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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 &bull; {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 &bull; {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>
);

View File

@@ -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;
}

View File

@@ -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,
});

View File

@@ -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();
});
});

View File

@@ -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 },
});
}
}

View File

@@ -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;
};

View File

@@ -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,
};
}

View File

@@ -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', () => {

View File

@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,15 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
}
}
}

View 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).

View 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;

View 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": {}
}

View 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$).*$"]
}
}
}
}

View 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';

View 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,
});

View 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>;

View 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
);
});
});
});

View 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);
}
}
}

View 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;
}

View 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"
}
]
}

View 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"]
}

View 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"
]
}

View File

@@ -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,
});

View File

@@ -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,
};
}

View File

@@ -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;

View 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;
}
};

View File

@@ -35,3 +35,4 @@ export { serverLogAction } from './serverLog';
export { getStripeClientSession } from './getStripeClientSession';
export { updateStripePaymentDetails } from './updateStripePaymentDetails';
export { setDefaultStripePaymentDetails } from './setDefaultStripePaymentDetails';
export { getExperimentsAction } from './getExperimentsAction';

View File

@@ -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 },
],
})

View File

@@ -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;
}

View File

@@ -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],
})

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -30,6 +30,9 @@ class RequestArgs {
@IsString()
userAgent!: string;
@IsString()
experimentationId!: string;
@IsObject()
params!: Record<string, string>;

View File

@@ -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,
};
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

View File

@@ -0,0 +1,15 @@
{
"jsc": {
"target": "es2017",
"parser": {
"syntax": "typescript",
"decorators": true,
"dynamicImport": true
},
"transform": {
"decoratorMetadata": true,
"legacyDecorator": true
}
}
}

View 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).

View 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;

View 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": {}
}

View 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$).*$"]
}
}
}
}

View 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';

View 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);
});
});
});

View 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);
}
}
}

View 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>;

View 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';
}
}

View 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,
});

View 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>;
}

View File

@@ -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();
});
});

View 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()}`;
}
}

View 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"
}
]
}

View 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"]
}

View 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"
]
}

View File

@@ -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"],