velody/backend/src/modules/users/entitlement.service.spec.ts
2026-06-28 15:10:57 +02:00

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