406 lines
11 KiB
TypeScript
406 lines
11 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { SubscriptionPlan, SubscriptionStatus } from '@prisma/client';
|
|
import {
|
|
ENTITLEMENT_KEYS,
|
|
ENTITLEMENT_SOURCES,
|
|
EntitlementService,
|
|
} from './entitlement.service';
|
|
|
|
describe('EntitlementService', () => {
|
|
const now = new Date('2026-06-28T12:00:00.000Z');
|
|
const future = new Date('2026-07-28T12:00:00.000Z');
|
|
const past = new Date('2026-06-27T12:00:00.000Z');
|
|
|
|
const buildService = () => {
|
|
const subscriptions = new Map<string, any>();
|
|
const entitlements = new Map<string, any>();
|
|
|
|
const entitlementId = (userId: string, entitlementKey: string) =>
|
|
`${userId}:${entitlementKey}`;
|
|
|
|
const userSubscription = {
|
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
const subscription = subscriptions.get(where.userId);
|
|
if (!subscription) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
plan: subscription.plan,
|
|
status: subscription.status,
|
|
currentPeriodEnd: subscription.currentPeriodEnd,
|
|
};
|
|
}),
|
|
};
|
|
|
|
const userEntitlement = {
|
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
|
const key = where.userId_entitlementKey;
|
|
const entitlement = entitlements.get(
|
|
entitlementId(key.userId, key.entitlementKey),
|
|
);
|
|
|
|
return entitlement ? { granted: entitlement.granted } : null;
|
|
}),
|
|
findMany: jest.fn().mockImplementation(async ({ where }) =>
|
|
Array.from(entitlements.values())
|
|
.filter((entitlement) => entitlement.userId === where.userId)
|
|
.sort((left, right) =>
|
|
left.entitlementKey.localeCompare(right.entitlementKey),
|
|
)
|
|
.map(
|
|
({ entitlementKey, granted, source, createdAt, updatedAt }) => ({
|
|
entitlementKey,
|
|
granted,
|
|
source,
|
|
createdAt,
|
|
updatedAt,
|
|
}),
|
|
),
|
|
),
|
|
upsert: jest
|
|
.fn()
|
|
.mockImplementation(async ({ where, update, create }) => {
|
|
const key = where.userId_entitlementKey;
|
|
const id = entitlementId(key.userId, key.entitlementKey);
|
|
const existing = entitlements.get(id);
|
|
const timestamp = new Date(now);
|
|
const entitlement = existing
|
|
? { ...existing, ...update, updatedAt: timestamp }
|
|
: {
|
|
id: randomUUID(),
|
|
...create,
|
|
createdAt: timestamp,
|
|
updatedAt: timestamp,
|
|
};
|
|
|
|
entitlements.set(id, entitlement);
|
|
|
|
return {
|
|
entitlementKey: entitlement.entitlementKey,
|
|
granted: entitlement.granted,
|
|
source: entitlement.source,
|
|
createdAt: entitlement.createdAt,
|
|
updatedAt: entitlement.updatedAt,
|
|
};
|
|
}),
|
|
updateMany: jest.fn().mockImplementation(async ({ where, data }) => {
|
|
let count = 0;
|
|
|
|
for (const [id, entitlement] of entitlements) {
|
|
if (
|
|
entitlement.userId === where.userId &&
|
|
where.entitlementKey.in.includes(entitlement.entitlementKey) &&
|
|
entitlement.source === where.source
|
|
) {
|
|
entitlements.set(id, {
|
|
...entitlement,
|
|
...data,
|
|
updatedAt: new Date(now),
|
|
});
|
|
count += 1;
|
|
}
|
|
}
|
|
|
|
return { count };
|
|
}),
|
|
createMany: jest.fn().mockImplementation(async ({ data }) => {
|
|
let count = 0;
|
|
|
|
for (const create of data) {
|
|
const id = entitlementId(create.userId, create.entitlementKey);
|
|
if (entitlements.has(id)) {
|
|
continue;
|
|
}
|
|
|
|
entitlements.set(id, {
|
|
id: randomUUID(),
|
|
...create,
|
|
createdAt: new Date(now),
|
|
updatedAt: new Date(now),
|
|
});
|
|
count += 1;
|
|
}
|
|
|
|
return { count };
|
|
}),
|
|
};
|
|
|
|
const prismaService = {
|
|
userSubscription,
|
|
userEntitlement,
|
|
} as any;
|
|
|
|
return {
|
|
service: new EntitlementService(prismaService),
|
|
subscriptions,
|
|
entitlements,
|
|
prismaService,
|
|
setSubscription: (
|
|
userId: string,
|
|
status: SubscriptionStatus,
|
|
plan: SubscriptionPlan,
|
|
currentPeriodEnd = future,
|
|
) => {
|
|
subscriptions.set(userId, {
|
|
userId,
|
|
status,
|
|
plan,
|
|
currentPeriodEnd,
|
|
providerCustomerId: 'provider-id-must-not-affect-plan',
|
|
providerSubscriptionId: 'provider-subscription-id',
|
|
});
|
|
},
|
|
setEntitlement: (
|
|
userId: string,
|
|
entitlementKey: string,
|
|
granted: boolean,
|
|
source: string,
|
|
) => {
|
|
entitlements.set(entitlementId(userId, entitlementKey), {
|
|
id: randomUUID(),
|
|
userId,
|
|
entitlementKey,
|
|
granted,
|
|
source,
|
|
createdAt: new Date(now),
|
|
updatedAt: new Date(now),
|
|
});
|
|
},
|
|
};
|
|
};
|
|
|
|
it('returns FREE when the user has no subscription', async () => {
|
|
const { service } = buildService();
|
|
|
|
await expect(service.getEffectivePlan(randomUUID(), now)).resolves.toBe(
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
});
|
|
|
|
it('returns PRO for an ACTIVE PRO subscription in its current period', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
|
|
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
});
|
|
|
|
it('returns PRO for a current TRIAL', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.TRIAL,
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
|
|
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
});
|
|
|
|
it('does not infer PRO from provider IDs on an ACTIVE FREE subscription', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
|
|
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
});
|
|
|
|
it('returns FREE for an ACTIVE PRO subscription past its period end', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionPlan.PRO,
|
|
past,
|
|
);
|
|
|
|
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
});
|
|
|
|
it.each([
|
|
SubscriptionStatus.PAST_DUE,
|
|
SubscriptionStatus.CANCELLED,
|
|
SubscriptionStatus.EXPIRED,
|
|
])('returns FREE for a current %s subscription', async (status) => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(userId, status, SubscriptionPlan.PRO);
|
|
|
|
await expect(service.getEffectivePlan(userId, now)).resolves.toBe(
|
|
SubscriptionPlan.FREE,
|
|
);
|
|
});
|
|
|
|
it('allows a directly granted entitlement on the FREE plan', async () => {
|
|
const { service } = buildService();
|
|
const userId = randomUUID();
|
|
|
|
await service.grantEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
);
|
|
|
|
await expect(
|
|
service.hasEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
now,
|
|
),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
it('allows Pro feature access from the effective plan before a sync', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
|
|
await expect(
|
|
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRO_LIBRARY, now),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
it('denies a revoked entitlement on the FREE plan', async () => {
|
|
const { service } = buildService();
|
|
const userId = randomUUID();
|
|
|
|
await service.revokeEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
);
|
|
|
|
await expect(
|
|
service.hasEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
now,
|
|
),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
it('grants all Pro entitlements when syncing an effective PRO plan', async () => {
|
|
const { service, setSubscription } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.ACTIVE,
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
|
|
const result = await service.syncEntitlementsForSubscription(userId, now);
|
|
|
|
expect(result).toHaveLength(EntitlementService.proEntitlementKeys.length);
|
|
expect(result).toEqual(
|
|
expect.arrayContaining(
|
|
EntitlementService.proEntitlementKeys.map((entitlementKey) =>
|
|
expect.objectContaining({
|
|
entitlementKey,
|
|
granted: true,
|
|
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
|
}),
|
|
),
|
|
),
|
|
);
|
|
});
|
|
|
|
it('revokes only subscription-sourced Pro entitlements when syncing FREE', async () => {
|
|
const { service, setEntitlement } = buildService();
|
|
const userId = randomUUID();
|
|
setEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.PRO_LIBRARY,
|
|
true,
|
|
ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
|
);
|
|
setEntitlement(
|
|
userId,
|
|
ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
true,
|
|
ENTITLEMENT_SOURCES.MANUAL,
|
|
);
|
|
|
|
const result = await service.syncEntitlementsForSubscription(userId, now);
|
|
|
|
expect(result).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
entitlementKey: ENTITLEMENT_KEYS.PRO_LIBRARY,
|
|
granted: false,
|
|
source: ENTITLEMENT_SOURCES.SUBSCRIPTION,
|
|
}),
|
|
expect.objectContaining({
|
|
entitlementKey: ENTITLEMENT_KEYS.SMART_DOWNLOADS,
|
|
granted: true,
|
|
source: ENTITLEMENT_SOURCES.MANUAL,
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it('does not create duplicate entitlements across repeated syncs', async () => {
|
|
const { service, setSubscription, entitlements } = buildService();
|
|
const userId = randomUUID();
|
|
setSubscription(
|
|
userId,
|
|
SubscriptionStatus.TRIAL,
|
|
SubscriptionPlan.PRO,
|
|
);
|
|
|
|
await service.syncEntitlementsForSubscription(userId, now);
|
|
await service.syncEntitlementsForSubscription(userId, now);
|
|
|
|
expect(
|
|
Array.from(entitlements.values()).filter(
|
|
(entitlement) => entitlement.userId === userId,
|
|
),
|
|
).toHaveLength(EntitlementService.proEntitlementKeys.length);
|
|
});
|
|
|
|
it('keeps entitlement reads, checks, and syncs scoped to the requested user', async () => {
|
|
const { service, setEntitlement } = buildService();
|
|
const userId = randomUUID();
|
|
const otherUserId = randomUUID();
|
|
setEntitlement(
|
|
otherUserId,
|
|
ENTITLEMENT_KEYS.PRIORITY_SYNC,
|
|
true,
|
|
ENTITLEMENT_SOURCES.MANUAL,
|
|
);
|
|
|
|
await expect(
|
|
service.hasEntitlement(userId, ENTITLEMENT_KEYS.PRIORITY_SYNC, now),
|
|
).resolves.toBe(false);
|
|
await expect(service.getUserEntitlements(userId)).resolves.toEqual([]);
|
|
|
|
await service.syncEntitlementsForSubscription(userId, now);
|
|
|
|
await expect(service.getUserEntitlements(otherUserId)).resolves.toEqual([
|
|
expect.objectContaining({
|
|
entitlementKey: ENTITLEMENT_KEYS.PRIORITY_SYNC,
|
|
granted: true,
|
|
}),
|
|
]);
|
|
});
|
|
});
|