feat(server): cleanup legacy compatibility (#15239)

This commit is contained in:
DarkSky
2026-07-15 03:01:51 +08:00
committed by GitHub
parent 00d4ab10a1
commit e145d87d56
104 changed files with 3184 additions and 8883 deletions
@@ -4,6 +4,7 @@ import Sinon from 'sinon';
import { AuthSessionService, CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EntitlementModule } from '../../core/entitlement';
import { FeatureModule } from '../../core/features';
import { QuotaModule } from '../../core/quota';
import { UserModule } from '../../core/user';
@@ -21,7 +22,7 @@ const test = ava as TestFn<{
test.before(async t => {
const m = await createTestingModule({
imports: [QuotaModule, FeatureModule, UserModule],
imports: [EntitlementModule, QuotaModule, FeatureModule, UserModule],
providers: [AuthService],
});
@@ -4,7 +4,7 @@ import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import ava, { type ExecutionContext, type TestFn } from 'ava';
import Sinon from 'sinon';
import { Cache, CryptoHelper } from '../../base';
import { Cache, ConfigFactory, CryptoHelper } from '../../base';
import { EntitlementService } from '../../core/entitlement';
import { Models, WorkspaceRole } from '../../models';
import { CopilotAccessPolicy } from '../../plugins/copilot/access';
@@ -553,11 +553,16 @@ test('local leases are short lived and do not persist keys to server configs', a
});
test('local leases persist normalized custom endpoints', async t => {
const customEndpointSupported = Sinon.stub(
t.context.byok,
'customEndpointSupported'
).get(() => true);
t.teardown(() => customEndpointSupported.restore());
const config = t.context.module.get(ConfigFactory);
const deploymentType = globalThis.env.DEPLOYMENT_TYPE;
Object.assign(globalThis.env, { DEPLOYMENT_TYPE: 'selfhosted' });
t.false(t.context.byok.customEndpointSupported);
config.override({ copilot: { byok: { allowCustomEndpoint: true } } });
t.true(t.context.byok.customEndpointSupported);
t.teardown(() => {
config.override({ copilot: { byok: { allowCustomEndpoint: false } } });
Object.assign(globalThis.env, { DEPLOYMENT_TYPE: deploymentType });
});
const { user, workspace } = await createUserWorkspace(t);
await grantUserPlan(t, user.id);
@@ -140,7 +140,7 @@ test('should be able to merge updates as snapshot', async t => {
await db.workspace.create({
data: {
id: '1',
public: false,
accessPolicy: { create: {} },
},
});
@@ -2,8 +2,14 @@ import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { installLicenseMutation, SubscriptionVariant } from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { Workspace, WorkspaceRole } from '../../../models';
import { LicenseService } from '../../../plugins/license/service';
import {
SubscriptionRecurring,
SubscriptionVariant as PaymentSubscriptionVariant,
} from '../../../plugins/payment/types';
import {
createApp,
e2e,
@@ -70,6 +76,59 @@ e2e('should install file license', async t => {
});
t.is(res.installLicense.variant, SubscriptionVariant.Onetime);
const db = app.get(PrismaClient);
const installed = await db.installedLicense.findUniqueOrThrow({
where: { workspaceId: workspace.id },
});
const staleAt = new Date(0);
await db.installedLicense.update({
where: { key: installed.key },
data: {
quantity: installed.quantity + 10,
recurring:
installed.recurring === SubscriptionRecurring.Monthly
? SubscriptionRecurring.Yearly
: SubscriptionRecurring.Monthly,
validatedAt: staleAt,
expiredAt: staleAt,
},
});
await db.entitlement.updateMany({
where: {
source: 'selfhost_license',
targetType: 'workspace',
targetId: workspace.id,
},
data: {
quantity: installed.quantity + 20,
validatedAt: staleAt,
expiresAt: staleAt,
},
});
await app.get(LicenseService).licensesHealthCheck();
const revalidated = await db.installedLicense.findUniqueOrThrow({
where: { key: installed.key },
});
t.is(revalidated.variant, PaymentSubscriptionVariant.Onetime);
t.is(revalidated.quantity, installed.quantity);
t.is(revalidated.recurring, installed.recurring);
t.is(revalidated.expiredAt?.getTime(), installed.expiredAt?.getTime());
t.true(revalidated.validatedAt.getTime() > staleAt.getTime());
const entitlement = await db.entitlement.findFirstOrThrow({
where: {
source: 'selfhost_license',
targetType: 'workspace',
targetId: workspace.id,
},
});
t.is(entitlement.quantity, installed.quantity);
t.is(entitlement.expiresAt?.getTime(), installed.expiredAt?.getTime());
t.true((entitlement.validatedAt?.getTime() ?? 0) > staleAt.getTime());
});
e2e('should not allow to install license if not owner', async t => {
@@ -298,9 +298,9 @@ e2e(
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, ARRAY[]::text[], NOW())
VALUES (${workspace.id}, 1, 100, 1, 50, 1, 1, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
@@ -309,7 +309,6 @@ e2e(
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
@@ -479,9 +478,9 @@ e2e(
await db.$executeRaw`
INSERT INTO workspace_admin_stats (
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, features, updated_at
workspace_id, snapshot_count, snapshot_size, blob_count, blob_size, member_count, public_page_count, updated_at
)
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, ARRAY[]::text[], NOW())
VALUES (${workspace.id}, 1, 130, 1, 70, 1, 0, NOW())
ON CONFLICT (workspace_id)
DO UPDATE SET
snapshot_count = EXCLUDED.snapshot_count,
@@ -490,7 +489,6 @@ e2e(
blob_size = EXCLUDED.blob_size,
member_count = EXCLUDED.member_count,
public_page_count = EXCLUDED.public_page_count,
features = EXCLUDED.features,
updated_at = EXCLUDED.updated_at
`;
@@ -6,8 +6,9 @@ import {
revokePublicPageMutation,
WorkspaceMemberStatus,
} from '@affine/graphql';
import { PrismaClient } from '@prisma/client';
import { PrismaClient, WorkspaceMemberSource } from '@prisma/client';
import { WorkspacePolicyService } from '../../../core/permission';
import { QuotaService } from '../../../core/quota/service';
import { WorkspaceRole } from '../../../models';
import {
@@ -109,8 +110,9 @@ const cancelTeamWorkspace = async (workspaceId: string) => {
},
data: { status: 'revoked' },
});
await db.subscription.updateMany({
await db.providerSubscription.updateMany({
where: {
targetType: 'workspace',
targetId: workspaceId,
plan: SubscriptionPlan.Team,
},
@@ -176,16 +178,16 @@ e2e('should allocate seats', async t => {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
await app.create(Mockers.WorkspaceUser, {
const linkInvitation = await app.create(Mockers.WorkspaceUser, {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
kind: 'link',
});
t.is(linkInvitation.source, WorkspaceMemberSource.Link);
const invitationCount = app.queue.count('notification.sendInvitation');
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
@@ -219,7 +221,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u1.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u2 = await app.createUser();
@@ -227,7 +228,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u2.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const u3 = await app.createUser();
@@ -235,7 +235,6 @@ e2e('should set all rests to NeedMoreSeat', async t => {
userId: u3.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Link',
});
await app.eventBus.emitAsync('workspace.members.allocateSeats', {
@@ -275,7 +274,6 @@ e2e(
userId: allocating.id,
workspaceId: workspace.id,
status: WorkspaceMemberStatus.AllocatingSeat,
source: 'Email',
});
const underReview = await app.create(Mockers.User);
@@ -313,10 +311,8 @@ e2e(
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
@@ -350,10 +346,8 @@ e2e(
t.false(await app.models.workspace.isTeamWorkspace(workspace.id));
t.true(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
t.is(
(await app.models.workspaceUser.get(workspace.id, admin.id))?.type,
@@ -371,10 +365,8 @@ e2e(
}
t.false(
await app.models.workspaceFeature.has(
workspace.id,
'quota_exceeded_readonly_workspace_v1'
)
(await app.get(WorkspacePolicyService).getWorkspaceState(workspace.id))
.isReadonly
);
}
);
@@ -1,16 +1,49 @@
import type { WorkspaceDoc } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput;
export type MockDocMetaInput = Prisma.WorkspaceDocUncheckedCreateInput & {
public?: boolean;
defaultRole?: DocRole;
};
export type MockedDocMeta = WorkspaceDoc;
export type MockedDocMeta = WorkspaceDoc & {
public: boolean;
defaultRole: DocRole;
};
export class MockDocMeta extends Mocker<MockDocMetaInput, MockedDocMeta> {
override async create(input: MockDocMetaInput) {
return await this.db.workspaceDoc.create({
data: input,
const {
public: isPublic = false,
defaultRole = DocRole.Manager,
...meta
} = input;
const doc = await this.db.workspaceDoc.create({
data: meta,
});
await this.db.docAccessPolicy.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
visibility: isPublic ? 'public' : 'private',
publicRole: isPublic ? 'external' : null,
memberDefaultRole:
defaultRole === DocRole.None
? 'none'
: defaultRole === DocRole.Reader
? 'reader'
: defaultRole === DocRole.Commenter
? 'commenter'
: defaultRole === DocRole.Editor
? 'editor'
: defaultRole === DocRole.Owner
? 'owner'
: 'manager',
},
});
return { ...doc, public: isPublic, defaultRole };
}
}
@@ -1,16 +1,35 @@
import type { WorkspaceDocUserRole } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { DocRole } from '../../models';
import { Mocker } from './factory';
export type MockDocUserInput = Prisma.WorkspaceDocUserRoleUncheckedCreateInput;
export type MockDocUserInput = {
workspaceId: string;
docId: string;
userId: string;
type: DocRole;
};
export type MockedDocUser = WorkspaceDocUserRole;
export type MockedDocUser = MockDocUserInput & { createdAt: Date };
export class MockDocUser extends Mocker<MockDocUserInput, MockedDocUser> {
override async create(input: MockDocUserInput) {
return await this.db.workspaceDocUserRole.create({
data: input,
const grant = await this.db.docGrant.create({
data: {
workspaceId: input.workspaceId,
docId: input.docId,
principalType: 'user',
principalId: input.userId,
role:
input.type === DocRole.Owner
? 'owner'
: input.type === DocRole.Manager
? 'manager'
: input.type === DocRole.Editor
? 'editor'
: input.type === DocRole.Commenter
? 'commenter'
: 'reader',
},
});
return { ...input, createdAt: grant.createdAt };
}
}
@@ -1,6 +1,5 @@
import { faker } from '@faker-js/faker';
import { Feature, FeatureType } from '../../models';
import { Mocker } from './factory';
interface MockTeamWorkspaceInput {
@@ -16,17 +15,6 @@ export class MockTeamWorkspace extends Mocker<
const id = input?.id ?? faker.string.uuid();
const quantity = input?.quantity ?? 10;
await this.db.subscription.create({
data: {
targetId: id,
plan: 'team',
recurring: 'monthly',
status: 'active',
start: faker.date.past(),
nextBillAt: faker.date.future(),
quantity,
},
});
await this.db.entitlement.create({
data: {
targetType: 'workspace',
@@ -38,19 +26,6 @@ export class MockTeamWorkspace extends Mocker<
},
});
await this.db.workspaceFeature.create({
data: {
workspaceId: id,
reason: 'test',
activated: true,
name: Feature.TeamPlan,
type: FeatureType.Quota,
configs: {
memberLimit: quantity,
},
},
});
return { id };
}
}
@@ -1,26 +1,68 @@
import type { Prisma, WorkspaceUserRole } from '@prisma/client';
import { WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import {
WorkspaceMemberStatus,
WorkspaceRole,
type WorkspaceUserCompat,
} from '../../models';
import { workspaceInvitationToCompat } from '../../models/workspace-user-compat';
import { Mocker } from './factory';
export type MockWorkspaceUserInput = Omit<
Prisma.WorkspaceUserRoleUncheckedCreateInput,
'type'
> & {
export type MockWorkspaceUserInput = {
workspaceId: string;
userId: string;
type?: WorkspaceRole;
status?: WorkspaceMemberStatus;
inviterId?: string | null;
kind?: 'email' | 'link';
};
export class MockWorkspaceUser extends Mocker<
MockWorkspaceUserInput,
WorkspaceUserRole
WorkspaceUserCompat
> {
override async create(input: MockWorkspaceUserInput) {
return await this.db.workspaceUserRole.create({
const type = input.type ?? WorkspaceRole.Collaborator;
const status = input.status ?? WorkspaceMemberStatus.Accepted;
if (status === WorkspaceMemberStatus.Accepted) {
const member = await this.db.workspaceMember.create({
data: {
workspaceId: input.workspaceId,
userId: input.userId,
role:
type === WorkspaceRole.Owner
? 'owner'
: type === WorkspaceRole.Admin
? 'admin'
: 'member',
state: 'active',
source: 'legacy',
},
});
return {
...member,
type,
status,
source: 'Email' as const,
inviterId: null,
};
}
const invitation = await this.db.workspaceInvitation.create({
data: {
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Accepted,
...input,
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
inviterUserId: input.inviterId,
requestedRole: type === WorkspaceRole.Admin ? 'admin' : 'member',
status:
status === WorkspaceMemberStatus.UnderReview
? 'waiting_review'
: status === WorkspaceMemberStatus.NeedMoreSeat ||
status === WorkspaceMemberStatus.AllocatingSeat ||
status === WorkspaceMemberStatus.NeedMoreSeatAndReview
? 'waiting_seat'
: 'pending',
kind: input.kind ?? 'email',
},
});
return workspaceInvitationToCompat(invitation);
}
}
@@ -2,15 +2,18 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { faker } from '@faker-js/faker';
import type { Prisma, Workspace } from '@prisma/client';
import type { Prisma } from '@prisma/client';
import { omit } from 'lodash-es';
import { WorkspaceRole } from '../../models';
import type { Workspace } from '../../models';
import { Mocker } from './factory';
export type MockWorkspaceInput = Prisma.WorkspaceCreateInput & {
owner?: { id: string };
snapshot?: Uint8Array | true;
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
};
export type MockedWorkspace = Workspace;
@@ -28,72 +31,51 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
input.snapshot = snapshot;
}
const snapshot = input?.snapshot;
input = omit(input, 'owner', 'snapshot');
const isPublic = input?.public ?? false;
const enableSharing = input?.enableSharing ?? true;
const enableUrlPreview = input?.enableUrlPreview ?? false;
input = omit(
input,
'owner',
'snapshot',
'public',
'enableSharing',
'enableUrlPreview'
);
const workspace = await this.db.workspace.create({
data: {
name: faker.animal.cat(),
public: false,
...input,
permissions: owner
accessPolicy: {
create: {
visibility: isPublic ? 'public' : 'private',
sharingEnabled: enableSharing,
urlPreviewEnabled: enableUrlPreview,
},
},
members: owner
? {
create: {
userId: owner.id,
type: WorkspaceRole.Owner,
status: 'Accepted',
role: 'owner',
state: 'active',
source: 'legacy',
},
}
: undefined,
},
});
const runtimeStateColumns = await this.db.$queryRaw<
Array<{ exists: boolean }>
>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`;
if (runtimeStateColumns[0]?.exists) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (${workspace.id}, true, false, ARRAY[]::TEXT[], now(), NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
known = true,
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
last_reconciled_at = now(),
stale_after = NULL,
updated_at = now()
`;
} else {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (${workspace.id}, false, ARRAY[]::TEXT[], NULL, now())
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = false,
readonly_reasons = ARRAY[]::TEXT[],
stale_at = NULL,
updated_at = now()
`;
}
await this.db.effectiveWorkspaceQuotaState.create({
data: {
workspaceId: workspace.id,
plan: 'free',
seatLimit: 0,
blobLimit: 0,
storageQuota: 0,
historyPeriodSeconds: 0,
known: true,
},
});
// create a rootDoc snapshot
if (snapshot) {
@@ -109,6 +91,11 @@ export class MockWorkspace extends Mocker<MockWorkspaceInput, MockedWorkspace> {
},
});
}
return workspace;
return {
...workspace,
public: isPublic,
enableSharing,
enableUrlPreview,
};
}
}
@@ -31,7 +31,7 @@ test.after(async () => {
async function create() {
return db.workspace.create({
data: { public: false },
data: { accessPolicy: { create: {} } },
});
}
@@ -142,12 +142,13 @@ test('should paginate doc user roles', async t => {
})),
});
await db.workspaceDocUserRole.createMany({
await db.docGrant.createMany({
data: Array.from({ length: 200 }, (_, i) => ({
workspaceId: workspace.id,
docId,
userId: String(i),
type: DocRole.Editor,
principalType: 'user',
principalId: String(i),
role: 'editor',
createdAt: new Date(Date.now() + i * 1000),
})),
});
@@ -3,8 +3,12 @@ import ava, { TestFn } from 'ava';
import { AdminFeatureManagementResolver } from '../../core/features/resolver';
import { AvailableUserFeatureConfig } from '../../core/features/types';
import { FeatureType, Models, UserFeatureModel, UserModel } from '../../models';
import { Feature } from '../../models/common/feature';
import {
Feature,
FeatureType,
UserFeatureModel,
UserModel,
} from '../../models';
import { createTestingModule, TestingModule } from '../utils';
interface Context {
@@ -18,7 +22,6 @@ const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(UserFeatureModel);
t.context.resolver = module.get(AdminFeatureManagementResolver);
t.context.module = module;
@@ -36,140 +39,39 @@ test.after(async t => {
await t.context.module.close();
});
test('configurable user features exclude commercial projection features', t => {
test('only administrator is a configurable user feature', t => {
const config = new AvailableUserFeatureConfig();
t.false(config.availableUserFeatures().has(Feature.UnlimitedCopilot));
t.false(config.configurableUserFeatures().has(Feature.UnlimitedCopilot));
});
test('admin feature resolver rejects commercial projection features', async t => {
await t.throwsAsync(
t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.ProPlan]),
{ message: /not configurable/ }
);
t.deepEqual(await t.context.model.list(t.context.u1.id), []);
t.deepEqual([...config.availableUserFeatures()], [Feature.Admin]);
t.deepEqual([...config.configurableUserFeatures()], [Feature.Admin]);
});
test('should get null if user feature not found', async t => {
const { model, u1 } = t.context;
const userFeature = await model.get(u1.id, 'administrator');
t.is(userFeature, null);
t.is(await t.context.model.get(t.context.u1.id, Feature.Admin), null);
});
test('should get user feature', async t => {
test('should add and get user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userFeature = await model.get(u1.id, 'free_plan_v1');
t.is(userFeature?.name, 'free_plan_v1');
});
test('should get user quota', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
const userQuota = await model.getQuota(u1.id);
t.snapshot(userQuota?.configs, 'free plan');
});
test('should list user features', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.like(await model.list(u1.id), ['free_plan_v1']);
});
test('should list user features by type', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'test');
await model.add(u1.id, 'unlimited_copilot', 'test');
t.like(await model.list(u1.id, FeatureType.Quota), ['free_plan_v1']);
t.like(await model.list(u1.id, FeatureType.Feature), ['unlimited_copilot']);
});
test('should directly test user feature existence', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
t.true(await model.has(u1.id, 'free_plan_v1'));
t.false(await model.has(u1.id, 'administrator'));
});
test('should add user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'unlimited_copilot', 'test');
t.true(await model.has(u1.id, 'unlimited_copilot'));
t.true((await model.list(u1.id)).includes('unlimited_copilot'));
await model.add(u1.id, Feature.Admin, 'test');
t.is((await model.get(u1.id, Feature.Admin))?.name, Feature.Admin);
t.true(await model.has(u1.id, Feature.Admin));
t.deepEqual(await model.list(u1.id, FeatureType.Feature), [Feature.Admin]);
});
test('should not add existing user feature', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'test');
await model.add(u1.id, 'free_plan_v1', 'test');
t.like(await model.list(u1.id), ['free_plan_v1']);
await model.add(u1.id, Feature.Admin, 'test');
await model.add(u1.id, Feature.Admin, 'test');
t.deepEqual(await model.list(u1.id), [Feature.Admin]);
});
test('should remove user feature', async t => {
const { model, u1 } = t.context;
await model.remove(u1.id, 'free_plan_v1');
t.false(await model.has(u1.id, 'free_plan_v1'));
t.false((await model.list(u1.id)).includes('free_plan_v1'));
await model.add(u1.id, Feature.Admin, 'test');
await model.remove(u1.id, Feature.Admin);
t.false(await model.has(u1.id, Feature.Admin));
});
test('should switch user quota', async t => {
const { model, u1 } = t.context;
await model.switchQuota(u1.id, 'pro_plan_v1', 'test');
const quota = await model.getQuota(u1.id);
t.snapshot(quota?.configs, 'switch to pro plan');
await model.switchQuota(u1.id, 'free_plan_v1', 'test');
const quota2 = await model.getQuota(u1.id);
t.snapshot(quota2?.configs, 'switch to free plan');
});
test('should not switch user quota if the new quota is the same as the current one', async t => {
const { model, u1 } = t.context;
await model.add(u1.id, 'free_plan_v1', 'legacy projection');
await model.switchQuota(u1.id, 'free_plan_v1', 'test not switch');
// @ts-expect-error private
const quota = await model.db.userFeature.findFirst({
where: {
userId: u1.id,
},
});
t.not(quota?.reason, 'test not switch');
});
test('should use pro plan as free for selfhost instance', async t => {
const previousDeploymentType = env.DEPLOYMENT_TYPE;
// @ts-expect-error DEPLOYMENT_TYPE is readonly
env.DEPLOYMENT_TYPE = 'selfhosted';
try {
await using module = await createTestingModule();
const models = module.get(Models);
const u1 = await models.user.create({
email: 'u1@affine.pro',
registered: true,
});
await models.userFeature.add(u1.id, 'free_plan_v1', 'legacy projection');
const quota = await models.userFeature.getQuota(u1.id);
t.snapshot(
quota?.configs,
'use pro plan as free plan for selfhosted instance'
);
} finally {
// @ts-expect-error DEPLOYMENT_TYPE is readonly
env.DEPLOYMENT_TYPE = previousDeploymentType;
}
test('admin resolver updates administrator feature', async t => {
await t.context.resolver.updateUserFeatures(t.context.u1.id, [Feature.Admin]);
t.true(await t.context.model.has(t.context.u1.id, Feature.Admin));
});
@@ -1,157 +0,0 @@
import { Workspace } from '@prisma/client';
import ava, { TestFn } from 'ava';
import { AdminWorkspaceResolver } from '../../core/workspaces/resolvers/admin';
import {
FeatureType,
UserModel,
WorkspaceFeatureModel,
WorkspaceModel,
} from '../../models';
import { createTestingModule, type TestingModule } from '../utils';
interface Context {
module: TestingModule;
model: WorkspaceFeatureModel;
resolver: AdminWorkspaceResolver;
ws: Workspace;
}
const test = ava as TestFn<Context>;
test.before(async t => {
const module = await createTestingModule({});
t.context.model = module.get(WorkspaceFeatureModel);
t.context.resolver = module.get(AdminWorkspaceResolver);
t.context.module = module;
});
test.beforeEach(async t => {
await t.context.module.initTestingDB();
const u1 = await t.context.module.get(UserModel).create({
email: 'u1@affine.pro',
registered: true,
});
t.context.ws = await t.context.module.get(WorkspaceModel).create(u1.id);
});
test.after(async t => {
await t.context.module.close();
});
test('should get null if workspace feature not found', async t => {
const { model, ws } = t.context;
const userFeature = await model.get(ws.id, 'unlimited_workspace');
t.is(userFeature, null);
});
test('admin workspace update changes workspace flags', async t => {
await t.context.resolver.adminUpdateWorkspace({
id: t.context.ws.id,
name: 'updated',
});
t.is(
(await t.context.module.get(WorkspaceModel).get(t.context.ws.id))?.name,
'updated'
);
});
test('should directly test workspace feature existence', async t => {
const { model, ws } = t.context;
t.false(await model.has(ws.id, 'unlimited_workspace'));
});
test('should get workspace quota', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
const quota = await model.getQuota(ws.id);
t.snapshot(quota?.configs, 'team plan');
});
test('should return null if quota removed', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', {
memberLimit: 100,
});
await model.remove(ws.id, 'team_plan_v1');
const quota = await model.getQuota(ws.id);
t.is(quota, null);
});
test('should list empty workspace features', async t => {
const { model, ws } = t.context;
t.deepEqual(await model.list(ws.id), []);
});
test('should list workspace features by type', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
await model.add(ws.id, 'team_plan_v1', 'test');
t.like(await model.list(ws.id, FeatureType.Quota), ['team_plan_v1']);
t.like(await model.list(ws.id, FeatureType.Feature), ['unlimited_workspace']);
});
test('should add workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'unlimited_workspace', 'test');
t.is(
(await model.get(ws.id, 'unlimited_workspace'))?.name,
'unlimited_workspace'
);
t.true(await model.has(ws.id, 'unlimited_workspace'));
t.true((await model.list(ws.id)).includes('unlimited_workspace'));
});
test('should add workspace feature with overrides', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
const f1 = await model.get(ws.id, 'team_plan_v1');
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 });
const f2 = await model.get(ws.id, 'team_plan_v1');
t.not(f1!.configs.memberLimit, f2!.configs.memberLimit);
t.is(f2!.configs.memberLimit, 100);
});
test('should not add existing workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
await model.add(ws.id, 'team_plan_v1', 'test');
t.like(await model.list(ws.id), ['team_plan_v1']);
});
test('should replace existing workspace if overrides updated', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 10 });
await model.add(ws.id, 'team_plan_v1', 'test', { memberLimit: 100 });
const f2 = await model.get(ws.id, 'team_plan_v1');
t.is(f2!.configs.memberLimit, 100);
});
test('should remove workspace feature', async t => {
const { model, ws } = t.context;
await model.add(ws.id, 'team_plan_v1', 'test');
await model.remove(ws.id, 'team_plan_v1');
t.false(await model.has(ws.id, 'team_plan_v1'));
t.false((await model.list(ws.id)).includes('team_plan_v1'));
});
@@ -27,14 +27,7 @@ test.after(async t => {
test('should get feature', async t => {
const { feature } = t.context;
const freePlanFeature = await feature.get('free_plan_v1');
const adminFeature = await feature.get('administrator');
t.snapshot(freePlanFeature.configs);
});
test('should throw if feature not found', async t => {
const { feature } = t.context;
await t.throwsAsync(feature.get('not_found_feature' as any), {
message: 'Feature not_found_feature not found',
});
t.deepEqual(adminFeature.configs, {});
});
@@ -1,621 +0,0 @@
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { PrismaClient } from '@prisma/client';
import test from 'ava';
import { PermissionProjectionChecker } from '../../core/permission/projection-checker';
import {
DocRole,
PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES,
PermissionProjectionModel,
permissionProjectionTriggerErrorCategory,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule } from '../create-module';
import { Mockers } from '../mocks';
const module = await createModule({});
const db = module.get(PrismaClient);
test.after.always(async () => {
await module.close();
});
test('payment provider facts migration makes nullable provider identities explicit', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260604000000_payment_provider_facts/migration.sql'
),
'utf8'
);
t.regex(
migration,
/provider_subscriptions_stripe_identity_check[\s\S]*"provider" <> 'stripe' OR "external_subscription_id" IS NOT NULL/
);
t.regex(
migration,
/provider_subscriptions_revenuecat_identity_check[\s\S]*"provider" <> 'revenuecat' OR \("iap_store" IS NOT NULL AND "external_ref" IS NOT NULL AND "external_product_id" IS NOT NULL AND "external_customer_id" IS NOT NULL\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_provider_external_subscription_id_key" ON "provider_subscriptions"\("provider", "external_subscription_id"\)/
);
t.regex(
migration,
/CREATE UNIQUE INDEX "provider_subscriptions_revenuecat_external_identity_key" ON "provider_subscriptions"\("provider", "iap_store", "external_ref", "external_product_id", "external_customer_id"\)/
);
});
class TestPermissionProjectionModel extends PermissionProjectionModel {
constructor(private readonly fakeDb: unknown) {
super();
}
protected override get db() {
return this.fakeDb as never;
}
}
let appliedPermissionProjectionTriggerFunctionUpdates = false;
async function applyPermissionProjectionTriggerFunctionUpdates() {
if (appliedPermissionProjectionTriggerFunctionUpdates) {
return;
}
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
for (const name of [
'affine_permission_project_new_workspace_member',
'affine_permission_project_new_workspace_invitation',
'affine_permission_project_new_doc_access_policy',
'affine_permission_project_new_doc_grant',
]) {
const sql = migration.match(
new RegExp(
`CREATE OR REPLACE FUNCTION ${name}\\(\\)[\\s\\S]*?END\\n\\$\\$;`
)
)?.[0];
if (!sql) {
throw new Error(`Missing migration function ${name}`);
}
await db.$executeRawUnsafe(sql);
}
appliedPermissionProjectionTriggerFunctionUpdates = true;
}
async function hasCurrentWorkspaceInvitationColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name IN ('requested_role', 'status', 'kind')
`;
return rows.length === 3;
}
test('PermissionProjectionModel checker returns mismatch and dirty-row counts', async t => {
const queryResults = [
[{ count: 1n }],
[{ count: 2n }],
[{ count: 3n }],
[{ count: 4n }],
[{ count: 5n }],
[{ count: 6n }],
[{ count: 7n }],
[{ count: 8n }],
[{ count: 9n }],
[{ count: 10n }],
[
{ category: 'legacy_doc_external_row', count: 11n },
{ category: 'doc_default_owner', count: 12n },
],
];
const model = new TestPermissionProjectionModel({
$queryRaw: async () => queryResults.shift(),
});
t.deepEqual(await model.checkLegacyProjection(), {
oldWorkspacePolicyMismatch: 1,
oldAcceptedMemberMismatch: 2,
extraProjectedMember: 3,
oldInvitationMismatch: 4,
extraProjectedInvitation: 5,
oldDocGrantMismatch: 6,
extraProjectedDocGrant: 7,
oldDocPolicyMismatch: 8,
extraProjectedDocPolicy: 9,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict: 10,
oldNewDecisionMismatch: 0,
invalidLegacyRows: {
legacy_doc_external_row: 11,
doc_default_owner: 12,
},
});
});
test('PermissionProjectionModel backfill runs with legacy origin in a long transaction', async t => {
const executed: unknown[] = [];
let transactionOptions: unknown;
const model = new TestPermissionProjectionModel({
$transaction: async (
callback: (tx: unknown) => Promise<void>,
options: unknown
) => {
transactionOptions = options;
await callback({
$executeRaw: async (query: unknown) => {
executed.push(query);
},
});
},
});
await model.backfillLegacyProjection();
t.is(executed.length, 11);
t.deepEqual(transactionOptions, { timeout: 10 * 60 * 1000 });
t.regex(String(executed[0]), /affine\.permission_sync_origin/);
});
test('PermissionProjectionModel exposes stable trigger metric categories', t => {
t.deepEqual(PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES, [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
]);
});
test('permission projection migration uses non-recursive origin guard', t => {
const migration = readFileSync(
join(
process.cwd(),
'migrations/20260512133700_workspace_runtime_states/migration.sql'
),
'utf8'
);
const guardBody = migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_legacy\(\)[\s\S]*?END\n\$\$;/
)?.[0];
t.truthy(guardBody);
t.true(
guardBody?.includes('IF NOT affine_permission_projection_enabled() THEN')
);
t.false(
guardBody?.includes('IF NOT affine_permission_should_project_from_legacy()')
);
t.truthy(
migration.match(
/CREATE OR REPLACE FUNCTION affine_permission_should_project_from_new\(\)[\s\S]*?IF NOT affine_permission_projection_enabled\(\) THEN[\s\S]*?END\n\$\$;/
)
);
});
test('permission projection trigger maps legacy workspace permission rows', async t => {
const workspace = await module.create(Mockers.Workspace);
const [admin, pending] = await module.create(Mockers.User, 2);
await db.workspaceUserRole.createMany({
data: [
{
workspaceId: workspace.id,
userId: admin.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
},
{
workspaceId: workspace.id,
userId: pending.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Pending,
},
],
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: admin.id,
state: 'active',
},
});
const invitation = await db.workspaceInvitation.findUniqueOrThrow({
where: {
workspaceId_inviteeUserId: {
workspaceId: workspace.id,
inviteeUserId: pending.id,
},
},
});
t.is(member.role, 'admin');
t.is(invitation.requestedRole, 'member');
t.is(invitation.status, 'pending');
});
test('permission projection trigger maps legacy doc policy rows', async t => {
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'public-doc',
public: true,
defaultRole: DocRole.Reader,
},
});
const policy = await db.docAccessPolicy.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'public-doc',
},
},
});
t.is(policy.visibility, 'public');
t.is(policy.publicRole, 'external');
t.is(policy.memberDefaultRole, 'reader');
});
async function hasDocGrantLegacyProjectionColumns() {
const rows = await db.$queryRaw<{ columnName: string }[]>`
SELECT column_name AS "columnName"
FROM information_schema.columns
WHERE table_name = 'doc_grants'
AND column_name IN (
'legacy_workspace_id',
'legacy_doc_id',
'legacy_user_id'
)
`;
return rows.length === 3;
}
test('permission projection trigger maps legacy doc grants and drops dirty rows', async t => {
if (!(await hasDocGrantLegacyProjectionColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates doc_grants legacy columns'
);
return;
}
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceDocUserRole.createMany({
data: [
{
workspaceId: workspace.id,
docId: 'valid-grant',
userId: user.id,
type: DocRole.Reader,
},
{
workspaceId: workspace.id,
docId: 'dirty-external',
userId: user.id,
type: DocRole.External,
},
{
workspaceId: workspace.id,
docId: 'dirty-none',
userId: user.id,
type: DocRole.None,
},
],
});
const grants = await db.docGrant.findMany({
where: {
workspaceId: workspace.id,
principalId: user.id,
},
orderBy: {
docId: 'asc',
},
});
t.deepEqual(
grants.map(grant => [grant.docId, grant.role]),
[['valid-grant', 'reader']]
);
});
test('permission projection trigger clears legacy row for non-active new workspace member states', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const member = await db.workspaceMember.create({
data: {
workspaceId: workspace.id,
userId: user.id,
role: 'member',
state: 'active',
},
});
t.truthy(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
);
await db.workspaceMember.update({
where: { id: member.id },
data: { state: 'suspended' },
});
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger clears legacy row for terminal new invitation statuses', async t => {
if (!(await hasCurrentWorkspaceInvitationColumns())) {
t.false(
Boolean(process.env.CI),
'current local test database predates workspace invitation projection columns'
);
return;
}
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
const [invitation] = await db.$queryRaw<{ id: string }[]>`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
requested_role,
status,
kind
)
VALUES (
${workspace.id},
${user.id},
'member',
'pending',
'email'
)
RETURNING id
`;
t.is(
(
await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
})
).status,
'Pending'
);
await db.$executeRaw`
UPDATE workspace_invitations
SET status = 'declined'
WHERE id = ${invitation.id}
`;
t.is(
await db.workspaceUserRole.findUnique({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: user.id,
},
},
}),
null
);
});
test('permission projection trigger preserves doc metadata when new doc policy is deleted', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
await db.workspaceDoc.create({
data: {
workspaceId: workspace.id,
docId: 'metadata-doc',
public: true,
defaultRole: DocRole.Reader,
mode: 1,
blocked: true,
title: 'Title',
summary: 'Summary',
publishedAt: new Date('2026-01-01T00:00:00Z'),
},
});
await db.docAccessPolicy.delete({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
const doc = await db.workspaceDoc.findUniqueOrThrow({
where: {
workspaceId_docId: {
workspaceId: workspace.id,
docId: 'metadata-doc',
},
},
});
t.is(doc.public, false);
t.is(doc.defaultRole, DocRole.Manager);
t.is(doc.publishedAt, null);
t.is(doc.mode, 1);
t.is(doc.blocked, true);
t.is(doc.title, 'Title');
t.is(doc.summary, 'Summary');
});
test('permission projection trigger ignores group doc grants on legacy projection', async t => {
await applyPermissionProjectionTriggerFunctionUpdates();
const workspace = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'user',
principalId: user.id,
role: 'reader',
},
});
await db.docGrant.create({
data: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
role: 'manager',
},
});
await db.docGrant.delete({
where: {
workspaceId_docId_principalType_principalId: {
workspaceId: workspace.id,
docId: 'group-doc',
principalType: 'group',
principalId: user.id,
},
},
});
const legacyGrant = await db.workspaceDocUserRole.findUniqueOrThrow({
where: {
workspaceId_docId_userId: {
workspaceId: workspace.id,
docId: 'group-doc',
userId: user.id,
},
},
});
t.is(legacyGrant.type, DocRole.Reader);
});
test('PermissionProjectionModel parses trigger error metric category', t => {
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:owner_conflict:duplicate owner')
),
'owner_conflict'
);
t.is(
permissionProjectionTriggerErrorCategory(
new Error('permission_projection_error:unexpected:nope')
),
'unknown'
);
t.is(permissionProjectionTriggerErrorCategory(new Error('other')), null);
});
test('PermissionProjectionChecker reports old/new loader decision mismatches', async t => {
const checker = new PermissionProjectionChecker(
{
workspace: {
findMany: async () => [],
},
$queryRaw: async () => [
{
category: 'active_member_doc',
workspaceId: 'w1',
docId: 'doc1',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'explicit_doc_grant',
workspaceId: 'w1',
docId: 'doc2',
userId: 'u1',
workspaceActions: null,
docActions: ['Doc.Read'],
},
{
category: 'workspace_invitation',
workspaceId: 'w1',
docId: null,
userId: 'u2',
workspaceActions: ['Workspace.Read'],
docActions: null,
},
],
} as never,
{
permissionProjection: {
checkLegacyProjection: async () => ({}),
},
} as never,
{
load: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: 'legacy' },
docs: input.docs
? [{ docId: input.docs[0].docId, marker: 'legacy' }]
: [],
}),
loadFromNewTables: async (input: { docs?: [{ docId: string }] }) => ({
version: 1,
workspace: { marker: input.docs ? 'legacy' : 'projection' },
docs: input.docs
? [
{
docId: input.docs[0].docId,
marker:
input.docs[0].docId === 'doc1' ? 'legacy' : 'projection',
},
]
: [],
}),
} as never,
{
evaluate: (input: unknown) => input,
} as never
);
t.deepEqual(await checker.checkLegacyProjection(), {
oldNewDecisionMismatch: 2,
});
});
@@ -5,7 +5,12 @@ import test from 'ava';
import Sinon from 'sinon';
import { EventBus, NewOwnerIsNotActiveMember } from '../../base';
import { Models, WorkspaceMemberStatus, WorkspaceRole } from '../../models';
import {
DocRole,
Models,
WorkspaceMemberStatus,
WorkspaceRole,
} from '../../models';
import { createModule, TestingModule } from '../create-module';
import { Mockers } from '../mocks';
@@ -48,10 +53,12 @@ test('should transfer workespace owner', async t => {
owner: { id: user.id },
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -78,10 +85,12 @@ test('should keep old owner as admin when transferring a team workspace', async
id: workspace.id,
quantity: 10,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.Accepted }
);
await models.workspaceUser.setOwner(workspace.id, user2.id);
@@ -99,11 +108,12 @@ test('should throw if transfer owner to non-active member', async t => {
instanceOf: NewOwnerIsNotActiveMember,
});
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user2.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await models.workspaceUser.set(
workspace.id,
user2.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.AllocatingSeat }
);
await t.throwsAsync(models.workspaceUser.setOwner(workspace.id, user2.id), {
instanceOf: NewOwnerIsNotActiveMember,
@@ -231,22 +241,7 @@ test('should delete workspace user role', async t => {
t.is(role, null);
});
test('should delete legacy-only external workspace user role', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
t.truthy(await models.workspaceUser.get(workspace.id, u1.id));
await models.workspaceUser.delete(workspace.id, u1.id);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
});
test('should convert existing workspace user role to legacy-only external role', async t => {
test('should remove workspace permission when changing a member to external', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
@@ -258,85 +253,35 @@ test('should convert existing workspace user role to legacy-only external role',
status: WorkspaceMemberStatus.Accepted,
}
);
const docId = 'external-member-doc';
await models.docUser.set(workspace.id, docId, u1.id, DocRole.Editor);
await models.workspaceUser.set(workspace.id, u1.id, WorkspaceRole.External, {
status: WorkspaceMemberStatus.Accepted,
});
const role = await models.workspaceUser.get(workspace.id, u1.id);
t.is(role?.type, WorkspaceRole.External);
t.is(await models.workspaceUser.get(workspace.id, u1.id), null);
t.is(
await db.workspaceMember.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
}),
0
);
});
test('should backfill legacy permission id for new workspace member writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Accepted,
}
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
t.is(
await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
userId: u1.id,
inviteeUserId: u1.id,
},
},
});
const member = await db.workspaceMember.findFirstOrThrow({
where: {
workspaceId: workspace.id,
userId: u1.id,
state: 'active',
},
});
t.is(member.legacyPermissionId, legacyRole.id);
});
test('should backfill legacy permission id for new workspace invitation writes', async t => {
const workspace = await module.create(Mockers.Workspace);
const u1 = await module.create(Mockers.User);
await models.workspaceUser.set(
workspace.id,
u1.id,
WorkspaceRole.Collaborator,
{
status: WorkspaceMemberStatus.Pending,
}
}),
0
);
t.is(
(await models.docUser.get(workspace.id, docId, u1.id))?.type,
DocRole.Editor
);
const legacyRole = await db.workspaceUserRole.findUniqueOrThrow({
where: {
workspaceId_userId: {
workspaceId: workspace.id,
userId: u1.id,
},
},
});
const invitation = await db.workspaceInvitation.findFirstOrThrow({
where: {
workspaceId: workspace.id,
inviteeUserId: u1.id,
},
});
t.is(invitation.legacyPermissionId, legacyRole.id);
});
test('should get user workspace roles with filter', async t => {
@@ -344,19 +289,19 @@ test('should get user workspace roles with filter', async t => {
const ws2 = await module.create(Mockers.Workspace);
const user = await module.create(Mockers.User);
await db.workspaceUserRole.createMany({
await db.workspaceMember.createMany({
data: [
{
workspaceId: ws1.id,
userId: user.id,
type: WorkspaceRole.Admin,
status: WorkspaceMemberStatus.Accepted,
role: 'admin',
state: 'active',
},
{
workspaceId: ws2.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
status: WorkspaceMemberStatus.Accepted,
role: 'member',
state: 'active',
},
],
});
@@ -381,14 +326,12 @@ test('should paginate workspace user roles', async t => {
})),
});
await db.workspaceUserRole.createMany({
await db.workspaceMember.createMany({
data: users.map((user, i) => ({
workspaceId: workspace.id,
userId: user.id,
type: WorkspaceRole.Collaborator,
status: Object.values(WorkspaceMemberStatus)[
Math.floor(Math.random() * Object.values(WorkspaceMemberStatus).length)
],
role: 'member',
state: 'active',
createdAt: new Date(Date.now() + i * 1000),
})),
});
@@ -421,19 +364,20 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async
const workspace = await module.create(Mockers.Workspace);
for (const user of users) {
await module.create(Mockers.WorkspaceUser, {
workspaceId: workspace.id,
userId: user.id,
status: WorkspaceMemberStatus.AllocatingSeat,
});
await models.workspaceUser.set(
workspace.id,
user.id,
WorkspaceRole.Collaborator,
{ status: WorkspaceMemberStatus.AllocatingSeat }
);
}
await models.workspaceUser.allocateSeats(workspace.id, 1);
let count = await db.workspaceUserRole.count({
let count = await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
status: 'pending',
},
});
@@ -441,10 +385,10 @@ test('should allocate seats for AllocatingSeat and NeedMoreSeat members', async
await models.workspaceUser.allocateSeats(workspace.id, 3);
count = await db.workspaceUserRole.count({
count = await db.workspaceInvitation.count({
where: {
workspaceId: workspace.id,
status: WorkspaceMemberStatus.Pending,
status: 'pending',
},
});
@@ -40,11 +40,15 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
canceledCount: 0,
dbObservability: {
externalProductId: 'app.affine.pro.Annual',
externalRef: 'rc:app.affine.pro.Annual',
iapStore: 'app_store',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-tx-1',
rcProductId: 'app.affine.pro.Annual',
},
lastActivated: {
plan: 'pro',
@@ -53,14 +57,14 @@ Generated by [AVA](https://avajs.dev).
subscriberCount: 1,
}
## should process expiration/refund by deleting subscription and emitting canceled
## should process expiration/refund by canceling subscription and emitting canceled
> should process expiration/refund and emit canceled
{
activatedEventCount: 0,
canceledEventCount: 2,
finalDBCount: 0,
canceledEventCount: 1,
finalDBCount: 1,
lastCanceled: {
plan: 'pro',
recurring: 'yearly',
@@ -116,12 +120,16 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
name: 'Pro monthly on iOS',
rec: {
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
iapStore: 'app_store',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
plan: 'pro',
provider: 'revenuecat',
rcEntitlement: 'Pro',
rcExternalRef: 'orig-ios-1',
rcProductId: 'app.affine.pro.Monthly',
recurring: 'monthly',
status: 'active',
},
@@ -130,12 +138,16 @@ Generated by [AVA](https://avajs.dev).
activatedCount: 1,
name: 'AI annual on Android',
rec: {
externalProductId: 'app.affine.pro.ai.Annual',
externalRef: 'rc:app.affine.pro.ai.Annual',
iapStore: 'play_store',
metadata: {
entitlement: 'AI',
isTrial: false,
willRenew: true,
},
plan: 'ai',
provider: 'revenuecat',
rcEntitlement: 'AI',
rcExternalRef: 'token-android-1',
rcProductId: 'app.affine.pro.ai.Annual',
recurring: 'yearly',
status: 'active',
},
@@ -148,7 +160,7 @@ Generated by [AVA](https://avajs.dev).
> should keep active after trial renewal
{
activatedCount: 2,
activatedCount: 1,
canceledCount: 0,
status: 'active',
}
@@ -159,7 +171,7 @@ Generated by [AVA](https://avajs.dev).
{
canceledCount: 1,
finalDBCount: 0,
finalDBCount: 1,
}
## should set canceledAt and keep active until expiration when will_renew is false (cancellation before period end)
@@ -188,7 +200,7 @@ Generated by [AVA](https://avajs.dev).
{
activatedCount: 0,
hasRCRecord: false,
hasRCRecord: true,
}
## should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records
@@ -196,18 +208,30 @@ Generated by [AVA](https://avajs.dev).
> should reconcile and fix missing or out-of-order states for revenuecat records
{
activatedCount: 1,
activatedCount: 0,
canceledCount: 0,
subscriberCount: 1,
hasActiveEntitlement: true,
records: [
{
externalRef: 'rc:app.affine.pro.Annual',
metadata: {
entitlement: 'Pro',
isTrial: false,
willRenew: true,
},
},
],
reusedPlaceholder: true,
subscriberCount: 2,
}
## should treat refund as early expiration and revoke immediately
> should delete record and emit canceled on refund
> should cancel record and emit canceled on refund
{
canceledEventCount: 2,
finalDBCount: 0,
canceledEventCount: 1,
finalDBCount: 1,
}
## should ignore non-whitelisted productId and not write to DB
@@ -236,11 +260,11 @@ Generated by [AVA](https://avajs.dev).
c: 0,
},
afterSecond: {
a: 3,
a: 2,
c: 0,
},
afterThird: {
a: 4,
a: 2,
c: 1,
},
},
@@ -1,5 +1,7 @@
import { TransactionHost } from '@nestjs-cls/transactional';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
import Sinon from 'sinon';
import { CryptoHelper, EventBus, JobQueue } from '../../base';
import { EntitlementService } from '../../core/entitlement';
@@ -127,16 +129,23 @@ test('onetime selfhost license seat allocation ignores projected license quantit
});
test('recurring selfhost license activation returns activation projection without remote health recheck', async t => {
const transactionHost = Sinon.stub(TransactionHost, 'getInstance').returns({
withTransaction: (...args: unknown[]) =>
(args.at(-1) as () => Promise<unknown>)(),
} as TransactionHost);
t.teardown(() => transactionHost.restore());
const events: Array<{ name: string; payload: unknown }> = [];
const upserts: unknown[] = [];
const entitlements: unknown[] = [];
const operations: string[] = [];
const expiresAt = Date.now() + 30 * 24 * 60 * 60 * 1000;
const service = new LicenseService(
{
installedLicense: {
findUnique: async () => null,
upsert: async (input: unknown) => {
create: async (input: unknown) => {
upserts.push(input);
operations.push('source');
return {
workspaceId: 'ws',
key: 'license-key',
@@ -156,6 +165,7 @@ test('recurring selfhost license activation returns activation projection withou
{
upsertFromValidatedSelfhostLicense: async (input: unknown) => {
entitlements.push(input);
operations.push('entitlement');
},
} as unknown as EntitlementService,
{} as unknown as QuotaStateService
@@ -186,6 +196,7 @@ test('recurring selfhost license activation returns activation projection withou
t.is(entitlements.length, 1);
t.is(upserts.length, 1);
t.is(activatedLicenseKey, 'license-key');
t.deepEqual(operations, ['source', 'entitlement']);
t.deepEqual(events, [
{
name: 'workspace.subscription.activated',
@@ -9,6 +9,7 @@ import {
SubscriptionAlreadyExists,
} from '../../base';
import { ConfigModule } from '../../base/config';
import { EntitlementService } from '../../core/entitlement';
import { FeatureService } from '../../core/features';
import { Models } from '../../models';
import { PaymentModule } from '../../plugins/payment';
@@ -107,12 +108,20 @@ test.beforeEach(async t => {
Sinon.stub(rc, 'getCustomerAlias').resolves([appUserId]);
t.context.mockSub = subs =>
Sinon.stub(rc, 'getSubscriptions').resolves(
subs.map(s => ({ ...s, customerId: customerId }))
subs.map(s => ({
...s,
customerId,
externalRef: s.externalRef ?? `rc:${s.productId}`,
}))
);
t.context.mockSubSeq = sequences => {
const stub = Sinon.stub(rc, 'getSubscriptions');
sequences.forEach((seq, idx) => {
const subs = seq.map(s => ({ ...s, customerId: customerId }));
const subs = seq.map(s => ({
...s,
customerId,
externalRef: s.externalRef ?? `rc:${s.productId}`,
}));
if (idx === 0) stub.onFirstCall().resolves(subs);
else if (idx === 1) stub.onSecondCall().resolves(subs);
else stub.onCall(idx).resolves(subs);
@@ -189,7 +198,7 @@ test('should resolve product mapping consistently (whitelist, override, unknown)
test('should standardize RC subscriber response and upsert subscription with observability fields', async t => {
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
mockAlias(user.id);
const alias = mockAlias(user.id);
const subscriber = mockSub([
{
identifier: 'Pro',
@@ -217,14 +226,14 @@ test('should standardize RC subscriber response and upsert subscription with obs
});
const { activatedCount, canceledCount, events } = collectEvents();
const record = await t.context.db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const record = await t.context.db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: {
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
metadata: true,
externalProductId: true,
externalRef: true,
},
});
@@ -241,20 +250,65 @@ test('should standardize RC subscriber response and upsert subscription with obs
},
'should standardize payload and have events'
);
const transferred = await t.context.models.user.create({
email: 'revenuecat-transfer@affine.pro',
});
alias.resolves([transferred.id]);
await webhook.onWebhook({
appUserId: transferred.id,
event: {
id: 'evt_1_transfer',
environment: 'PRODUCTION',
app_id: 'app.affine.pro',
type: 'TRANSFER',
store: 'app_store',
original_transaction_id: 'orig-tx-1',
},
});
t.is(
(
await t.context.db.providerSubscription.findFirstOrThrow({
where: {
provider: 'revenuecat',
externalRef: 'rc:app.affine.pro.Annual',
},
})
).targetId,
transferred.id
);
t.true(
t.context.event.emit.calledWith('entitlement.changed', {
targetType: 'user',
targetId: user.id,
})
);
t.true(
t.context.event.emit.calledWith('user.subscription.canceled', {
userId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
})
);
});
test('should process expiration/refund by deleting subscription and emitting canceled', async t => {
test('should process expiration/refund by canceling subscription and emitting canceled', async t => {
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Annual',
externalRef: 'rc:app.affine.pro.Annual',
},
});
@@ -279,7 +333,7 @@ test('should process expiration/refund by deleting subscription and emitting can
original_transaction_id: 'orig-tx-2',
});
const finalDBCount = await db.subscription.count({
const finalDBCount = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
@@ -304,33 +358,47 @@ test('should enqueue per-user reconciliation jobs for existing RC active/trialin
const cron = module.get(SubscriptionCronJobs);
const common = { provider: 'revenuecat', start: new Date() } as const;
await db.subscription.createMany({
const common = { provider: 'revenuecat', periodStart: new Date() } as const;
await db.providerSubscription.createMany({
data: [
{
targetType: 'user',
targetId: 'u1',
plan: 'pro',
status: 'active',
recurring: 'monthly',
iapStore: 'app_store',
externalCustomerId: 'c1',
externalProductId: 'pro-monthly',
externalRef: 'r1',
...common,
},
{
targetType: 'user',
targetId: 'u2',
plan: 'ai',
status: 'trialing',
recurring: 'yearly',
iapStore: 'app_store',
externalCustomerId: 'c2',
externalProductId: 'ai-yearly',
externalRef: 'r2',
...common,
},
{
targetType: 'user',
targetId: 'u1',
plan: 'ai',
status: 'past_due',
recurring: 'monthly',
iapStore: 'play_store',
externalCustomerId: 'c1',
externalProductId: 'ai-monthly',
externalRef: 'r3',
...common,
},
],
});
await cron.reconcileRevenueCatSubscriptions();
const calls = module.queue.add.getCalls().map(c => ({
@@ -411,17 +479,21 @@ test('should activate subscriptions via webhook for whitelisted products across
// reset event history between scenarios for clean counts
event.emit.resetHistory?.();
await triggerWebhook(user.id, s.event);
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: s.expectedPlan } },
const rec = await db.providerSubscription.findFirst({
where: {
targetType: 'user',
targetId: user.id,
plan: s.expectedPlan,
},
select: {
plan: true,
recurring: true,
status: true,
provider: true,
iapStore: true,
rcEntitlement: true,
rcProductId: true,
rcExternalRef: true,
metadata: true,
externalProductId: true,
externalRef: true,
},
});
const { activatedCount } = collectEvents();
@@ -479,9 +551,9 @@ test('should keep active and advance period dates when a trialing subscription r
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
select: { status: true, start: true, end: true },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true, periodStart: true, periodEnd: true },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
@@ -535,7 +607,7 @@ test('should remove or cancel the record and revoke entitlement when a trialing
store: 'app_store',
});
const finalDBCount = await db.subscription.count({
const finalDBCount = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
@@ -564,8 +636,8 @@ test('should set canceledAt and keep active until expiration when will_renew is
type: 'CANCELLATION',
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true, canceledAt: true },
});
const { activatedCount, canceledCount } = collectEvents();
@@ -602,8 +674,8 @@ test('should retain record as past_due (inactive but not expired) and NOT emit c
store: 'app_store',
});
const rec = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const rec = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { status: true },
});
const { canceledCount } = collectEvents();
@@ -617,18 +689,25 @@ test('should block checkout when an existing subscription of the same plan is ac
const { module, db } = t.context;
const manager = module.get(UserSubscriptionManager);
let subscriptionId: string;
{
await db.subscription.create({
const subscription = await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
},
});
subscriptionId = subscription.id;
await t.throwsAsync(
manager.checkout(
@@ -649,9 +728,16 @@ test('should block checkout when an existing subscription of the same plan is ac
}
{
await db.subscription.update({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
data: { provider: 'stripe' },
await db.providerSubscription.update({
where: { id: subscriptionId },
data: {
provider: 'stripe',
externalSubscriptionId: 'sub_existing',
iapStore: null,
externalCustomerId: null,
externalProductId: null,
externalRef: null,
},
});
await t.throwsAsync(
@@ -677,17 +763,18 @@ test('should block checkout when an existing subscription of the same plan is ac
test('should skip RC upsert when Stripe active already exists for same plan', async t => {
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'stripe',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_conflict',
},
});
mockSub([
{
identifier: 'Pro',
@@ -708,7 +795,7 @@ test('should skip RC upsert when Stripe active already exists for same plan', as
store: 'app_store',
});
const rcRec = await db.subscription.findFirst({
const rcRec = await db.providerSubscription.findFirst({
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
});
const { activatedCount } = collectEvents();
@@ -720,19 +807,23 @@ test('should skip RC upsert when Stripe active already exists for same plan', as
test('should block read-write ops on revenuecat-managed record (cancel/resume/updateRecurring)', async t => {
const { db, service } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date(),
periodStart: new Date(),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:managed',
},
});
// local helper used multiple times within this test
const expectManaged = async (fn: () => Promise<any>) =>
const expectManaged = async (fn: () => Promise<unknown>) =>
t.throwsAsync(() => fn(), { instanceOf: ManagedByAppStoreOrPlay });
await expectManaged(() =>
@@ -752,29 +843,83 @@ test('should block read-write ops on revenuecat-managed record (cancel/resume/up
});
test('should reconcile and fix missing or out-of-order states for revenuecat Active/Trialing/PastDue records', async t => {
const { webhook, collectEvents, mockAlias, mockSub } = t.context;
const { webhook, db, collectEvents, mockAlias, mockSubSeq } = t.context;
mockAlias(user.id);
const subscriber = mockSub([
{
identifier: 'Pro',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'play_store',
willRenew: true,
duration: null,
const placeholder = await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2999-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: user.id,
externalProductId: 'legacy_product:1',
externalRef: 'legacy_subscription:1',
metadata: { legacyRevenueCatIdentityIncomplete: true },
},
]);
});
const subscription = {
identifier: 'Pro',
isTrial: false,
isActive: true,
latestPurchaseDate: new Date('2025-03-01T00:00:00.000Z'),
expirationDate: new Date('2026-03-01T00:00:00.000Z'),
productId: 'app.affine.pro.Annual',
store: 'play_store',
willRenew: true,
duration: null,
} as const;
const subscriber = mockSubSeq([[subscription], [subscription]]);
await webhook.syncAppUser(user.id);
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'yearly',
periodEnd: new Date('2999-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: user.id,
externalProductId: 'legacy_product:2',
externalRef: 'legacy_subscription:2',
metadata: { legacyRevenueCatIdentityIncomplete: true },
},
});
await webhook.syncAppUser(user.id);
const { activatedCount, canceledCount } = collectEvents();
const subscriberCount = subscriber.getCalls()?.length || 0;
const records = await db.providerSubscription.findMany({
where: { targetId: user.id, plan: 'pro', provider: 'revenuecat' },
select: { id: true, externalRef: true, metadata: true },
});
const normalizedRecords = records.map(({ id: _, ...record }) => record);
const activeEntitlement = await db.entitlement.findFirst({
where: {
targetType: 'user',
targetId: user.id,
source: 'cloud_subscription',
status: 'active',
subjectId: placeholder.id,
},
});
t.snapshot(
{ subscriberCount, activatedCount, canceledCount },
{
subscriberCount,
activatedCount,
canceledCount,
records: normalizedRecords,
reusedPlaceholder: records[0]?.id === placeholder.id,
hasActiveEntitlement: !!activeEntitlement,
},
'should reconcile and fix missing or out-of-order states for revenuecat records'
);
});
@@ -783,14 +928,19 @@ test('should treat refund as early expiration and revoke immediately', async t =
const { db, collectEvents, mockAlias, mockSub, triggerWebhook } = t.context;
mockAlias(user.id);
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
status: 'active',
provider: 'revenuecat',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
periodStart: new Date('2025-01-01T00:00:00.000Z'),
iapStore: 'app_store',
externalCustomerId: 'cust',
externalProductId: 'app.affine.pro.Monthly',
externalRef: 'rc:app.affine.pro.Monthly',
},
});
@@ -814,13 +964,13 @@ test('should treat refund as early expiration and revoke immediately', async t =
store: 'app_store',
});
const count = await db.subscription.count({
const count = await db.providerSubscription.count({
where: { targetId: user.id, plan: 'pro' },
});
const { canceledCount } = collectEvents();
t.snapshot(
{ finalDBCount: count, canceledEventCount: canceledCount },
'should delete record and emit canceled on refund'
'should cancel record and emit canceled on refund'
);
});
@@ -846,7 +996,9 @@ test('should ignore non-whitelisted productId and not write to DB', async t => {
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const dbCount = await db.subscription.count({ where: { targetId: user.id } });
const dbCount = await db.providerSubscription.count({
where: { targetId: user.id },
});
const { activatedCount, canceledCount } = collectEvents();
t.snapshot(
{ dbCount, activatedCount, canceledCount },
@@ -901,8 +1053,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const r1 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'pro' } },
const r1 = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'pro' },
select: { plan: true, recurring: true, provider: true },
});
const s1 = collectEvents();
@@ -913,8 +1065,8 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'play_store',
});
const r2 = await db.subscription.findUnique({
where: { targetId_plan: { targetId: user.id, plan: 'ai' } },
const r2 = await db.providerSubscription.findFirst({
where: { targetType: 'user', targetId: user.id, plan: 'ai' },
select: { plan: true, recurring: true, provider: true },
});
const s2 = collectEvents();
@@ -925,7 +1077,9 @@ test('should map via entitlement+duration when productId not whitelisted (P1M/P1
type: 'INITIAL_PURCHASE',
store: 'app_store',
});
const count = await db.subscription.count({ where: { targetId: user.id } });
const count = await db.providerSubscription.count({
where: { targetId: user.id },
});
const s3 = collectEvents();
t.snapshot(
@@ -1025,18 +1179,19 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy
// case3: only stripe subscription -> should NOT sync (call count remains 2)
{
await db.subscription.deleteMany({
await db.providerSubscription.deleteMany({
where: { targetId: user.id, provider: 'revenuecat' },
});
await db.subscription.create({
await db.providerSubscription.create({
data: {
targetType: 'user',
targetId: user.id,
plan: 'pro',
provider: 'stripe',
status: 'active',
recurring: 'monthly',
start: new Date('2025-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_123',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_123',
},
});
const subs = await subResolver.refreshUserSubscriptions(currentUser);
@@ -1048,30 +1203,49 @@ test('should refresh user subscriptions (empty / revenuecat / stripe-only)', asy
test('user subscriptions ignore active rows after their current period ended', async t => {
const { db, subResolver } = t.context;
await db.subscription.createMany({
await db.providerSubscription.createMany({
data: [
{
targetType: 'user',
targetId: user.id,
plan: 'ai',
provider: 'stripe',
status: 'active',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
end: new Date('2025-01-08T00:00:00.000Z'),
stripeSubscriptionId: 'sub_expired_ai',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2025-01-08T00:00:00.000Z'),
externalSubscriptionId: 'sub_expired_ai',
},
{
targetType: 'user',
targetId: user.id,
plan: 'pro',
provider: 'stripe',
status: 'active',
recurring: 'yearly',
start: new Date('2025-01-01T00:00:00.000Z'),
end: new Date('2099-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_current_pro',
periodStart: new Date('2025-01-01T00:00:00.000Z'),
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
externalSubscriptionId: 'sub_current_pro',
},
],
});
const entitlement = t.context.module.get(EntitlementService);
await entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_expired_ai',
end: new Date('2025-01-08T00:00:00.000Z'),
});
await entitlement.upsertFromCloudSubscription({
targetId: user.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
subscriptionId: 'sub_current_pro',
end: new Date('2099-01-01T00:00:00.000Z'),
});
const subscriptions = await subResolver.subscriptions(user, user);
t.deepEqual(subscriptions.map(subscription => subscription.plan).sort(), [
@@ -1092,18 +1266,6 @@ test('user subscriptions preserve provider trialing status', async t => {
email: `${Date.now()}-trial-status@affine.pro`,
});
await db.subscription.create({
data: {
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
provider: 'stripe',
status: SubscriptionStatus.Trialing,
recurring: SubscriptionRecurring.Yearly,
start: new Date('2026-01-01T00:00:00.000Z'),
end: new Date('2099-01-01T00:00:00.000Z'),
stripeSubscriptionId: 'sub_trialing_status',
},
});
await db.providerSubscription.create({
data: {
provider: 'stripe',
@@ -1117,6 +1279,14 @@ test('user subscriptions preserve provider trialing status', async t => {
periodEnd: new Date('2099-01-01T00:00:00.000Z'),
},
});
await t.context.module.get(EntitlementService).upsertFromCloudSubscription({
targetId: trialUser.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Trialing,
subscriptionId: 'sub_trialing_status',
end: new Date('2099-01-01T00:00:00.000Z'),
});
const subscriptions = await subResolver.subscriptions(trialUser, trialUser);
@@ -10,7 +10,9 @@ import { EventBus } from '../../base';
import { ConfigFactory, ConfigModule } from '../../base/config';
import { CurrentUser } from '../../core/auth';
import { AuthService } from '../../core/auth/service';
import { EntitlementService } from '../../core/entitlement';
import { SubscriptionCronJobs } from '../../plugins/payment/cron';
import { SelfhostTeamSubscriptionManager } from '../../plugins/payment/manager';
import { RevenueCatService } from '../../plugins/payment/revenuecat';
import { SubscriptionService } from '../../plugins/payment/service';
import { StripeFactory } from '../../plugins/payment/stripe';
@@ -226,7 +228,7 @@ test.beforeEach(async t => {
await db.workspace.create({
data: {
id: 'ws_1',
public: false,
accessPolicy: { create: {} },
},
});
await db.userStripeCustomer.create({
@@ -365,15 +367,17 @@ test('should list normal prices for user with old ai subscriptions', async t =>
test('should throw if user has subscription already', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
@@ -394,15 +398,17 @@ test('should throw if user has subscription already', async t => {
test('should allow checkout after local subscription period ended', async t => {
const { service, u1, db, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_expired_ai',
externalSubscriptionId: 'sub_expired_ai',
plan: SubscriptionPlan.AI,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date('2026-05-04T13:11:45.000Z'),
end: new Date('2026-05-11T13:11:45.000Z'),
periodStart: new Date('2026-05-04T13:11:45.000Z'),
periodEnd: new Date('2026-05-11T13:11:45.000Z'),
},
});
@@ -710,10 +716,6 @@ test('should be able to create subscription', async t => {
await service.saveStripeSubscription(sub);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.true(
event.emit.calledOnceWith('user.subscription.activated', {
userId: u1.id,
@@ -721,8 +723,6 @@ test('should be able to create subscription', async t => {
recurring: SubscriptionRecurring.Monthly,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
const providerFact = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
@@ -760,26 +760,33 @@ test('should be able to update subscription', async t => {
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: sub.id,
},
},
});
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.canceledAt?.getTime(), canceledAt * 1000);
});
test('should replace old subscription row when stripe creates a new subscription for the same plan', async t => {
test('should preserve old provider fact when stripe creates a new subscription for the same plan', async t => {
const { service, db, u1 } = t.context;
const old = await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_old',
externalSubscriptionId: 'sub_old',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Canceled,
start: new Date('2026-03-26T08:23:57.000Z'),
end: new Date('2027-03-26T08:23:57.000Z'),
periodStart: new Date('2026-03-26T08:23:57.000Z'),
periodEnd: new Date('2027-03-26T08:23:57.000Z'),
},
});
@@ -801,14 +808,21 @@ test('should replace old subscription row when stripe creates a new subscription
},
});
const subscriptions = await db.subscription.findMany({
where: { targetId: u1.id, plan: SubscriptionPlan.Pro },
const subscriptions = await db.providerSubscription.findMany({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
plan: SubscriptionPlan.Pro,
},
orderBy: { externalSubscriptionId: 'asc' },
});
t.is(subscriptions.length, 1);
t.is(subscriptions[0].id, old.id);
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new');
t.is(subscriptions.length, 2);
t.is(subscriptions[0].externalSubscriptionId, 'sub_new');
t.is(subscriptions[0].status, SubscriptionStatus.Active);
t.is(subscriptions[1].externalSubscriptionId, 'sub_old');
t.is(subscriptions[1].status, SubscriptionStatus.Canceled);
});
test('should be able to delete subscription', async t => {
@@ -828,11 +842,6 @@ test('should be able to delete subscription', async t => {
})
);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
});
t.is(subInDB, null);
t.like(
await db.providerSubscription.findUnique({
where: {
@@ -851,15 +860,18 @@ test('should be able to delete subscription', async t => {
test('should be able to cancel subscription', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
metadata: { preserved: true },
},
});
@@ -881,6 +893,15 @@ test('should be able to cancel subscription', async t => {
);
t.is(subInDB.status, SubscriptionStatus.Active);
t.truthy(subInDB.canceledAt);
const saved = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.like(saved.metadata, { preserved: true, nextBillAt: null });
});
test('should reconcile canceled stripe subscriptions and revoke local entitlement', async t => {
@@ -897,11 +918,16 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen
await cron.reconcileStripeSubscriptions();
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id, stripeSubscriptionId: sub.id },
const subInDB = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: sub.id,
},
},
});
t.is(subInDB, null);
t.is(subInDB?.status, SubscriptionStatus.Canceled);
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: u1.id,
@@ -914,15 +940,17 @@ test('should reconcile canceled stripe subscriptions and revoke local entitlemen
test('should be able to resume subscription', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
canceledAt: new Date(),
},
});
@@ -976,15 +1004,17 @@ const subscriptionSchedule: Stripe.SubscriptionSchedule = {
test('should be able to update recurring', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
@@ -1034,16 +1064,18 @@ test('should be able to update recurring', async t => {
test('should release the schedule if the new recurring is the same as the current phase', async t => {
const { service, db, u1, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
stripeScheduleId: 'sub_sched_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
metadata: { stripeScheduleId: 'sub_sched_1' },
},
});
@@ -1253,14 +1285,16 @@ test('should be able to checkout for lifetime recurring', async t => {
test('should not be able to checkout for lifetime recurring if already subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: null,
externalSubscriptionId: 'stripe_invoice:existing_lifetime',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
periodStart: new Date(),
},
});
@@ -1287,8 +1321,13 @@ test('should be able to subscribe to lifetime recurring', async t => {
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findFirst({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
});
t.true(
@@ -1301,7 +1340,7 @@ test('should be able to subscribe to lifetime recurring', async t => {
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`);
const paymentFact = await db.paymentEvent.findUnique({
where: {
@@ -1319,28 +1358,88 @@ test('should be able to subscribe to lifetime recurring', async t => {
currency: lifetimeInvoice.currency,
processingStatus: 'processed',
});
t.context.stripe.invoices.retrieve.resolves(
lifetimeInvoice as Stripe.Response<Stripe.Invoice>
);
await service.handleRefundedInvoice(lifetimeInvoice.id, 'refund');
const entitlement = await db.entitlement.findFirstOrThrow({
where: {
source: 'cloud_subscription',
subjectId: `stripe_invoice:${lifetimeInvoice.id}`,
},
});
t.is(entitlement.status, 'revoked');
});
test('should be able to subscribe to lifetime recurring with old subscription', async t => {
const { service, stripe, db, u1, event } = t.context;
const { app, service, stripe, db, u1, event } = t.context;
await db.subscription.create({
const previous = await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
},
});
await app.get(EntitlementService).upsertFromCloudSubscription({
targetId: u1.id,
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
subscriptionId: previous.id,
stripeSubscriptionId: previous.externalSubscriptionId,
end: previous.periodEnd,
});
event.emit.resetHistory();
stripe.subscriptions.cancel.rejects(new Error('remote cancellation failed'));
await t.throwsAsync(() => service.saveStripeInvoice(lifetimeInvoice), {
message: 'remote cancellation failed',
});
const current = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.is(current.status, SubscriptionStatus.Active);
t.is(
(
await db.entitlement.findFirstOrThrow({
where: { source: 'cloud_subscription', subjectId: 'sub_1' },
})
).status,
'active'
);
t.is(
await db.providerSubscription.count({
where: {
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
}),
0
);
stripe.subscriptions.cancel.resolves(sub as any);
await service.saveStripeInvoice(lifetimeInvoice);
const subInDB = await db.subscription.findFirst({
where: { targetId: u1.id },
const subInDB = await db.providerSubscription.findFirst({
where: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
recurring: SubscriptionRecurring.Lifetime,
},
});
t.true(
@@ -1353,20 +1452,36 @@ test('should be able to subscribe to lifetime recurring with old subscription',
t.is(subInDB?.plan, SubscriptionPlan.Pro);
t.is(subInDB?.recurring, SubscriptionRecurring.Lifetime);
t.is(subInDB?.status, SubscriptionStatus.Active);
t.is(subInDB?.stripeSubscriptionId, null);
t.is(subInDB?.externalSubscriptionId, `stripe_invoice:${lifetimeInvoice.id}`);
t.is(
(
await db.entitlement.findFirstOrThrow({
where: { source: 'cloud_subscription', subjectId: 'sub_1' },
})
).status,
'revoked'
);
t.is(stripe.subscriptions.cancel.callCount, 2);
t.deepEqual(
stripe.subscriptions.cancel.firstCall.lastArg,
stripe.subscriptions.cancel.secondCall.lastArg
);
});
test('should not be able to cancel lifetime subscription', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
externalSubscriptionId: 'stripe_invoice:lifetime_cancel',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: null,
periodStart: new Date(),
periodEnd: null,
},
});
@@ -1383,14 +1498,17 @@ test('should not be able to cancel lifetime subscription', async t => {
test('should not be able to update lifetime recurring', async t => {
const { service, db, u1 } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'user',
targetId: u1.id,
externalSubscriptionId: 'stripe_invoice:lifetime_update',
plan: SubscriptionPlan.Pro,
recurring: SubscriptionRecurring.Lifetime,
status: SubscriptionStatus.Active,
start: new Date(),
end: null,
periodStart: new Date(),
periodEnd: null,
},
});
@@ -1444,15 +1562,17 @@ test('should be able to checkout for team', async t => {
test('should not be able to checkout for workspace if subscribed', async t => {
const { service, u1, db } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Active,
start: new Date(),
end: new Date(Date.now() + 100000),
periodStart: new Date(),
periodEnd: new Date(Date.now() + 100000),
quantity: 1,
},
});
@@ -1478,15 +1598,17 @@ test('should not be able to checkout for workspace if subscribed', async t => {
test('should be able to checkout for workspace after canceled subscription', async t => {
const { service, u1, db, stripe } = t.context;
await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_1',
externalSubscriptionId: 'sub_1',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Monthly,
status: SubscriptionStatus.Canceled,
start: new Date(Date.now() - 100000),
end: new Date(Date.now() - 1000),
periodStart: new Date(Date.now() - 100000),
periodEnd: new Date(Date.now() - 1000),
quantity: 1,
},
});
@@ -1537,8 +1659,8 @@ test('should be able to create team subscription', async t => {
await service.saveStripeSubscription(teamSub);
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
const subInDB = await db.providerSubscription.findFirst({
where: { targetType: 'workspace', targetId: 'ws_1' },
});
t.true(
@@ -1549,21 +1671,23 @@ test('should be able to create team subscription', async t => {
quantity: 1,
})
);
t.is(subInDB?.stripeSubscriptionId, sub.id);
t.is(subInDB?.externalSubscriptionId, sub.id);
});
test('should replace old team subscription row when stripe creates a new subscription', async t => {
test('should preserve old team provider fact when stripe creates a new subscription', async t => {
const { service, db } = t.context;
const old = await db.subscription.create({
await db.providerSubscription.create({
data: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
stripeSubscriptionId: 'sub_old_team',
externalSubscriptionId: 'sub_old_team',
plan: SubscriptionPlan.Team,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Canceled,
start: new Date('2026-03-26T08:23:57.000Z'),
end: new Date('2027-03-26T08:23:57.000Z'),
periodStart: new Date('2026-03-26T08:23:57.000Z'),
periodEnd: new Date('2027-03-26T08:23:57.000Z'),
quantity: 24,
},
});
@@ -1583,15 +1707,22 @@ test('should replace old team subscription row when stripe creates a new subscri
},
});
const subscriptions = await db.subscription.findMany({
where: { targetId: 'ws_1', plan: SubscriptionPlan.Team },
const subscriptions = await db.providerSubscription.findMany({
where: {
provider: 'stripe',
targetType: 'workspace',
targetId: 'ws_1',
plan: SubscriptionPlan.Team,
},
orderBy: { externalSubscriptionId: 'asc' },
});
t.is(subscriptions.length, 1);
t.is(subscriptions[0].id, old.id);
t.is(subscriptions[0].stripeSubscriptionId, 'sub_new_team');
t.is(subscriptions.length, 2);
t.is(subscriptions[0].externalSubscriptionId, 'sub_new_team');
t.is(subscriptions[0].status, SubscriptionStatus.Active);
t.is(subscriptions[0].quantity, 11);
t.is(subscriptions[1].externalSubscriptionId, 'sub_old_team');
t.is(subscriptions[1].status, SubscriptionStatus.Canceled);
});
test('should be able to update team subscription', async t => {
@@ -1612,8 +1743,8 @@ test('should be able to update team subscription', async t => {
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
const subInDB = await db.providerSubscription.findFirst({
where: { targetType: 'workspace', targetId: 'ws_1' },
});
t.is(subInDB?.quantity, 2);
@@ -1654,9 +1785,6 @@ test('should persist mutable team subscription fields on same stripe subscriptio
},
});
const subInDB = await db.subscription.findFirst({
where: { targetId: 'ws_1' },
});
const entitlement = await db.entitlement.findFirst({
where: {
source: 'cloud_subscription',
@@ -1672,11 +1800,11 @@ test('should persist mutable team subscription fields on same stripe subscriptio
},
});
t.like(subInDB, {
t.like(providerFact, {
recurring: SubscriptionRecurring.Yearly,
quantity: 9,
start: new Date(1780000000 * 1000),
end: new Date(1811536000 * 1000),
periodStart: new Date(1780000000 * 1000),
periodEnd: new Date(1811536000 * 1000),
trialStart: new Date(1780000000 * 1000),
trialEnd: new Date(1780604800 * 1000),
});
@@ -1748,11 +1876,16 @@ test('should suspend on dispute and restore when dispute won', async t => {
await service.handleRefundedInvoice(invoice.id, 'dispute_open');
const removed = await db.subscription.findFirst({
where: { stripeSubscriptionId: 'sub_1' },
const suspended = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.is(removed, null);
t.is(suspended?.status, SubscriptionStatus.Canceled);
t.true(
event.emit.calledWith('user.subscription.canceled', {
userId: t.context.u1.id,
@@ -1766,8 +1899,13 @@ test('should suspend on dispute and restore when dispute won', async t => {
await service.handleRefundedInvoice(invoice.id, 'dispute_won');
const restored = await db.subscription.findFirst({
where: { stripeSubscriptionId: 'sub_1' },
const restored = await db.providerSubscription.findUnique({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: 'sub_1',
},
},
});
t.truthy(restored);
@@ -1781,6 +1919,99 @@ test('should suspend on dispute and restore when dispute won', async t => {
);
});
test('should keep selfhost provider identity and license source on replay and cancellation', async t => {
const { app, db, service } = t.context;
const selfhostSubscription = {
...sub,
id: 'sub_selfhost',
schedule: 'schedule_selfhost',
items: {
...sub.items,
data: [
{
...sub.items.data[0],
quantity: 5,
subscription: 'sub_selfhost',
price: {
id: 'price_selfhost',
lookup_key: `${SubscriptionPlan.SelfHostedTeam}_${SubscriptionRecurring.Yearly}`,
product: 'product_selfhost',
currency: 'usd',
unit_amount: 12000,
},
},
],
},
} as Stripe.Subscription;
await service.saveStripeSubscription(selfhostSubscription);
const first = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: selfhostSubscription.id,
},
},
});
await db.providerSubscription.create({
data: {
provider: 'revenuecat',
targetType: 'instance',
targetId: first.targetId,
plan: SubscriptionPlan.SelfHostedTeam,
recurring: SubscriptionRecurring.Yearly,
status: SubscriptionStatus.Active,
iapStore: 'app_store',
externalCustomerId: 'selfhost-customer',
externalProductId: 'selfhost-product',
externalRef: 'selfhost-ref',
},
});
const selected = await app
.get(SelfhostTeamSubscriptionManager)
.getSubscription({
key: first.targetId,
plan: SubscriptionPlan.SelfHostedTeam,
});
t.is(selected?.provider, 'stripe');
await service.saveStripeSubscription({
...selfhostSubscription,
items: {
...selfhostSubscription.items,
data: [{ ...selfhostSubscription.items.data[0], quantity: 7 }],
},
});
const replayed = await db.providerSubscription.findUniqueOrThrow({
where: {
provider_externalSubscriptionId: {
provider: 'stripe',
externalSubscriptionId: selfhostSubscription.id,
},
},
});
t.is(replayed.targetId, first.targetId);
t.is(replayed.quantity, 7);
t.is(await db.license.count({ where: { key: first.targetId } }), 1);
t.is(app.mails.count('TeamLicense'), 1);
await service.deleteStripeSubscription({
...selfhostSubscription,
status: SubscriptionStatus.Canceled,
});
t.is(
(
await db.providerSubscription.findUniqueOrThrow({
where: { id: first.id },
})
).status,
SubscriptionStatus.Canceled
);
t.is(await db.license.count({ where: { key: first.targetId } }), 1);
});
// NOTE(@forehalo): cancel and resume a team subscription share the same logic with user subscription
test.skip('should be able to cancel team subscription', async () => {});
test.skip('should be able to resume team subscription', async () => {});
@@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { PrismaClient, WorkspaceMemberStatus } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
import { WorkspaceRole } from '../../core/permission';
import { UserType } from '../../core/user/types';
@Injectable()
@@ -11,12 +10,12 @@ export class WorkspaceResolverMock {
async createWorkspace(user: UserType, _init: null) {
const workspace = await this.prisma.workspace.create({
data: {
public: false,
permissions: {
accessPolicy: { create: {} },
members: {
create: {
type: WorkspaceRole.Owner,
role: 'owner',
state: 'active',
userId: user.id,
status: WorkspaceMemberStatus.Accepted,
},
},
},
@@ -8,7 +8,7 @@ import { ConfigFactory } from '../../base';
import { QuotaStateService } from '../../core/quota/state';
import { WorkspaceBlobStorage } from '../../core/storage/wrappers/blob';
import { StorageRuntimeProvider } from '../../core/storage-runtime';
import { BlobModel, WorkspaceFeatureModel } from '../../models';
import { BlobModel } from '../../models';
import { getMime } from '../../native';
import {
collectAllBlobSizes,
@@ -34,7 +34,6 @@ const RESTRICTED_QUOTA = {
};
let app: TestingApp;
let model: WorkspaceFeatureModel;
type CompleteResult =
| {
ok: true;
@@ -146,7 +145,6 @@ test.before(async () => {
builder.overrideProvider(StorageRuntimeProvider).useValue(storageRuntime);
},
});
model = app.get(WorkspaceFeatureModel);
app.get(ConfigFactory).override({
storages: {
blob: {
@@ -447,18 +445,6 @@ test('should reject blob exceeded storage quota', async t => {
});
});
test('should accept blob even storage out of quota if workspace has unlimited feature', async t => {
await app.signupV1('u1@affine.pro');
const workspace = await createWorkspace(app);
await model.add(workspace.id, 'team_plan_v1', 'test', RESTRICTED_QUOTA);
await model.add(workspace.id, 'unlimited_workspace', 'test');
const buffer = Buffer.from(Array.from({ length: OneMB }, () => 0));
await t.notThrowsAsync(setBlob(app, workspace.id, buffer));
await t.notThrowsAsync(setBlob(app, workspace.id, buffer));
});
test('should throw error when blob size large than max file size', async t => {
await app.signup();
@@ -7,9 +7,7 @@ import Sinon from 'sinon';
import supertest from 'supertest';
import { applyUpdate, Doc as YDoc, Map as YMap } from 'yjs';
import { ConfigFactory } from '../../base';
import { PgWorkspaceDocStorageAdapter } from '../../core/doc';
import { PermissionReadModel } from '../../core/permission/config';
import { WorkspaceBlobStorage } from '../../core/storage';
import { Models, PublicDocMode, WorkspaceRole } from '../../models';
import {
@@ -52,11 +50,10 @@ test.before(async t => {
workspace: {
create: {
id: 'public',
public: true,
accessPolicy: { create: { visibility: 'public' } },
},
},
docId: 'private',
public: false,
},
});
@@ -65,11 +62,10 @@ test.before(async t => {
workspace: {
create: {
id: 'private',
public: false,
accessPolicy: { create: {} },
},
},
docId: 'public',
public: true,
},
});
@@ -78,13 +74,28 @@ test.before(async t => {
workspace: {
create: {
id: 'totally-private',
public: false,
accessPolicy: { create: {} },
},
},
docId: 'private',
public: false,
},
});
await db.docAccessPolicy.createMany({
data: [
{ workspaceId: 'public', docId: 'private', visibility: 'private' },
{
workspaceId: 'private',
docId: 'public',
visibility: 'public',
publicRole: 'external',
},
{
workspaceId: 'totally-private',
docId: 'private',
visibility: 'private',
},
],
});
});
test.after.always(async t => {
@@ -154,31 +165,6 @@ test('should be able to get private workspace with public pages', async t => {
t.is(res.text, 'blob');
});
test('should be able to get private workspace with public pages using new permission model', async t => {
const { app, storage } = t.context;
const config = app.get(ConfigFactory);
config.override({
permission: {
readModel: PermissionReadModel.Projection,
},
});
try {
storage.get.resolves(blob());
const res = await app.GET('/api/workspaces/private/blobs/test');
t.is(res.status, HttpStatus.OK);
t.is(res.get('content-type'), 'text/plain');
t.is(res.text, 'blob');
} finally {
config.override({
permission: {
readModel: PermissionReadModel.Legacy,
},
});
}
});
test('should not be able to get private workspace with no public pages', async t => {
const { app } = t.context;