mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-23 05:25:53 +08:00
feat(server): cleanup legacy compatibility (#15239)
This commit is contained in:
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user