Skip to content

Commit db66fc1

Browse files
committed
Reduced duplicate members test churn
ref https://linear.app/ghost/issue/BER-3506/rework-feature-flagging-for-release This tightens the members action unit coverage, shares the export assertions across the legacy and React member list suites, and moves saved-view interactions onto the page objects instead of protected internals.
1 parent 00d337a commit db66fc1

8 files changed

Lines changed: 241 additions & 324 deletions

File tree

apps/posts/test/unit/views/members/members-actions.test.tsx

Lines changed: 45 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -48,95 +48,72 @@ vi.mock('@tryghost/admin-x-framework/api/members', () => ({
4848
})
4949
}));
5050

51+
const defaultProps = {
52+
hasFilterOrSearch: false,
53+
memberCount: 10,
54+
search: '',
55+
canBulkDelete: true
56+
} as const;
57+
58+
const setLocation = (pathname: string, search = '') => {
59+
mockUseLocation.mockReturnValue({pathname, search});
60+
};
61+
62+
const setMembersForward = (enabled: boolean) => {
63+
mockUseBrowseConfig.mockReturnValue({
64+
data: {
65+
config: {
66+
labs: {
67+
membersForward: enabled
68+
}
69+
}
70+
}
71+
});
72+
};
73+
74+
const renderMembersActions = (props: Partial<React.ComponentProps<typeof MembersActions>> = {}) => {
75+
return render(
76+
<MembersActions
77+
{...defaultProps}
78+
{...props}
79+
/>
80+
);
81+
};
82+
5183
describe('MembersActions', () => {
5284
beforeEach(() => {
5385
importModalPropsRef.current = null;
54-
mockUseLocation.mockReturnValue({
55-
pathname: '/members',
56-
search: ''
57-
});
86+
setLocation('/members');
5887
mockUseNavigate.mockReturnValue(vi.fn());
59-
mockUseBrowseConfig.mockReturnValue({
60-
data: {
61-
config: {
62-
labs: {}
63-
}
64-
}
65-
});
88+
setMembersForward(false);
6689
});
6790

6891
it('does not open the import modal on the import route when membersForward is disabled', () => {
69-
mockUseLocation.mockReturnValue({
70-
pathname: '/members/import'
71-
});
92+
setLocation('/members/import');
7293

73-
render(
74-
<MembersActions
75-
hasFilterOrSearch={false}
76-
memberCount={10}
77-
search=""
78-
canBulkDelete
79-
onImportComplete={vi.fn()}
80-
/>
81-
);
94+
renderMembersActions({onImportComplete: vi.fn()});
8295

8396
expect(importModalPropsRef.current).not.toBeNull();
8497
expect(importModalPropsRef.current?.open).toBe(false);
8598
});
8699

87100
it('opens the import modal when membersForward is enabled on the import route', () => {
88-
mockUseLocation.mockReturnValue({
89-
pathname: '/members/import'
90-
});
91-
mockUseBrowseConfig.mockReturnValue({
92-
data: {
93-
config: {
94-
labs: {
95-
membersForward: true
96-
}
97-
}
98-
}
99-
});
101+
setLocation('/members/import');
102+
setMembersForward(true);
100103

101-
render(
102-
<MembersActions
103-
hasFilterOrSearch={false}
104-
memberCount={10}
105-
search=""
106-
canBulkDelete
107-
onImportComplete={vi.fn()}
108-
/>
109-
);
104+
renderMembersActions({onImportComplete: vi.fn()});
110105

111106
expect(importModalPropsRef.current).not.toBeNull();
112107
expect(importModalPropsRef.current?.open).toBe(true);
113108
});
114109

115110
it('navigates back to members when the import route modal closes', () => {
116111
const navigate = vi.fn();
117-
mockUseLocation.mockReturnValue({
118-
pathname: '/members/import',
119-
search: '?filter=label%3AVIP&search=alice'
120-
});
112+
setLocation('/members/import', '?filter=label%3AVIP&search=alice');
121113
mockUseNavigate.mockReturnValue(navigate);
122-
mockUseBrowseConfig.mockReturnValue({
123-
data: {
124-
config: {
125-
labs: {
126-
membersForward: true
127-
}
128-
}
129-
}
130-
});
114+
setMembersForward(true);
131115

132-
render(
133-
<MembersActions
134-
hasFilterOrSearch={false}
135-
memberCount={10}
136-
search=""
137-
canBulkDelete
138-
/>
139-
);
116+
renderMembersActions();
140117

141118
expect(importModalPropsRef.current).not.toBeNull();
142119

@@ -151,29 +128,11 @@ describe('MembersActions', () => {
151128

152129
it('navigates to the imported label filter when the import route modal closes after a labeled import', () => {
153130
const navigate = vi.fn();
154-
mockUseLocation.mockReturnValue({
155-
pathname: '/members/import',
156-
search: '?filter=label%3AVIP&search=alice'
157-
});
131+
setLocation('/members/import', '?filter=label%3AVIP&search=alice');
158132
mockUseNavigate.mockReturnValue(navigate);
159-
mockUseBrowseConfig.mockReturnValue({
160-
data: {
161-
config: {
162-
labs: {
163-
membersForward: true
164-
}
165-
}
166-
}
167-
});
133+
setMembersForward(true);
168134

169-
render(
170-
<MembersActions
171-
hasFilterOrSearch={false}
172-
memberCount={10}
173-
search=""
174-
canBulkDelete
175-
/>
176-
);
135+
renderMembersActions();
177136
expect(importModalPropsRef.current).not.toBeNull();
178137
const handleImportClose = importModalPropsRef.current?.onClose as ((importResponse?: {importLabel?: {slug: string}}) => void) | undefined;
179138
expect(handleImportClose).toBeTypeOf('function');

e2e/helpers/pages/admin/members/members-list-page.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,23 @@ export class MembersListPage extends AdminPage {
5555
await this.actionsButton.click();
5656
}
5757

58+
async applyLabelFilter(labelName: string): Promise<void> {
59+
await this.addSearchableFilter('Label', labelName, labelName);
60+
}
61+
62+
async getVisibleMemberCount(): Promise<number> {
63+
return await this.memberRows.count();
64+
}
65+
66+
async saveCurrentView(name: string): Promise<void> {
67+
await this.membersPage.getByRole('button', {name: 'Save view'}).click();
68+
const dialog = this.page.getByRole('dialog');
69+
await dialog.waitFor({state: 'visible'});
70+
await dialog.getByRole('textbox', {name: 'View name'}).fill(name);
71+
await dialog.getByRole('button', {name: 'Save'}).click();
72+
await dialog.waitFor({state: 'hidden'});
73+
}
74+
5875
getMenuItem(name: string | RegExp): Locator {
5976
return this.page.getByRole('menuitem', {name});
6077
}

e2e/helpers/pages/admin/members/members-page.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,18 @@ export class MembersPage extends AdminPage {
138138
await this.memberListItems.filter({hasText: email}).click();
139139
}
140140

141+
async openActionsMenu(): Promise<void> {
142+
await this.membersActionsButton.click();
143+
}
144+
145+
async applyLabelFilter(labelName: string): Promise<void> {
146+
await this.filterSection.applyLabel(labelName);
147+
}
148+
149+
async getVisibleMemberCount(): Promise<number> {
150+
return await this.memberListItems.count();
151+
}
152+
141153
async getMaxRenderedIndex(): Promise<number> {
142154
return await this.memberListItems.evaluateAll((rows) => {
143155
return rows.reduce((maxIndex, row) => {
Lines changed: 5 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -1,139 +1,8 @@
1-
import {expect, test} from '@/helpers/playwright';
2-
import {usePerTestIsolation} from '@/helpers/playwright/isolation';
3-
4-
import {MemberFactory, createMemberFactory} from '@/data-factory';
51
import {MembersPage} from '@/helpers/pages';
2+
import {runMembersExportTests} from '@/tests/admin/members/shared/export-suite';
63

7-
usePerTestIsolation();
8-
9-
test.describe('Ghost Admin - Member Export', () => {
10-
test.use({labs: {membersForward: false}});
11-
12-
let memberFactory: MemberFactory;
13-
14-
function extractDownloadedContentSpecifics(content: string) {
15-
const contentIds = content.match(/[a-z0-9]{24}/gm);
16-
const contentTimestamps = content.match(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/gm);
17-
18-
return {
19-
contentIds,
20-
contentTimestamps
21-
};
22-
}
23-
24-
const downloadedContentFields = [
25-
'id,',
26-
'email,',
27-
'name,',
28-
'note,',
29-
'subscribed_to_emails,',
30-
'complimentary_plan,',
31-
'stripe_customer_id,',
32-
'created_at,',
33-
'deleted_at,',
34-
'labels,',
35-
'tiers'
36-
];
37-
38-
const membersFixture = [
39-
{
40-
name: 'Test Member 1',
41-
email: 'test@member1.com',
42-
note: 'This is a test member',
43-
labels: ['old']
44-
},
45-
{
46-
name: 'Test Member 2',
47-
email: 'test@member2.com',
48-
note: 'This is a test member',
49-
labels: ['old']
50-
},
51-
{
52-
name: 'Test Member 3',
53-
email: 'test@member3.com',
54-
note: 'This is a test member',
55-
labels: ['old']
56-
},
57-
{
58-
name: 'Sashi',
59-
email: 'test@member4.com',
60-
note: 'This is a test member',
61-
labels: ['dog']
62-
},
63-
{
64-
name: 'Mia',
65-
email: 'test@member5.com',
66-
note: 'This is a test member',
67-
labels: ['dog']
68-
},
69-
{
70-
name: 'Minki',
71-
email: 'test@member6.com',
72-
note: 'This is a test member',
73-
labels: ['dog']
74-
}
75-
];
76-
77-
test.beforeEach(async ({page}) => {
78-
memberFactory = createMemberFactory(page.request);
79-
});
80-
81-
test('exports all members to CSV', async ({page}) => {
82-
await memberFactory.createMany(membersFixture);
83-
84-
const membersPage = new MembersPage(page);
85-
await membersPage.goto();
86-
await membersPage.membersActionsButton.click();
87-
const {suggestedFilename, content} = await membersPage.exportMembers();
88-
const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content);
89-
90-
expect(content).toMatch(new RegExp(downloadedContentFields.join('')));
91-
92-
membersFixture.forEach((member) => {
93-
expect(content).toContain(member.name);
94-
expect(content).toContain(member.email);
95-
expect(content).toContain(member.note);
96-
expect(content).toContain(member.labels[0]);
97-
});
98-
99-
expect(contentIds).toHaveLength(6);
100-
expect(contentTimestamps).toHaveLength(6);
101-
102-
expect(suggestedFilename.startsWith('members')).toBe(true);
103-
expect(suggestedFilename.endsWith('.csv')).toBe(true);
104-
});
105-
106-
test('exports filtered members by label to CSV', async ({page}) => {
107-
await memberFactory.createMany(membersFixture);
108-
const labelToFilterBy = 'dog';
109-
110-
const membersPage = new MembersPage(page);
111-
await membersPage.goto();
112-
await membersPage.filterSection.applyLabel(labelToFilterBy);
113-
await expect(membersPage.memberListItems).toHaveCount(3);
114-
115-
await membersPage.membersActionsButton.click();
116-
await expect(membersPage.exportMembersButton).toContainText('Export selected members');
117-
118-
const {suggestedFilename, content} = await membersPage.exportMembers();
119-
const {contentTimestamps, contentIds} = extractDownloadedContentSpecifics(content);
120-
121-
const fixture = membersFixture
122-
.filter(member => member.labels[0] === 'dog');
123-
124-
expect(content).toMatch(new RegExp(downloadedContentFields.join('')));
125-
126-
fixture.forEach((member) => {
127-
expect(content).toContain(member.name);
128-
expect(content).toContain(member.email);
129-
expect(content).toContain(member.note);
130-
expect(content).toContain(labelToFilterBy);
131-
});
132-
133-
expect(contentIds).toHaveLength(3);
134-
expect(contentTimestamps).toHaveLength(3);
135-
136-
expect(suggestedFilename.startsWith('members')).toBe(true);
137-
expect(suggestedFilename.endsWith('.csv')).toBe(true);
138-
});
4+
runMembersExportTests({
5+
suiteName: 'Ghost Admin - Member Export',
6+
labs: {membersForward: false},
7+
createPage: page => new MembersPage(page)
1398
});

0 commit comments

Comments
 (0)