Skip to content

Commit f55e1be

Browse files
authored
Merge pull request #329 from Itodo-S/issue-215-admin-dashboard
feat: implement subscription web dashboard with admin controls (#215)
2 parents 709360f + d4cc405 commit f55e1be

2 files changed

Lines changed: 207 additions & 0 deletions

File tree

src/services/__tests__/adminDashboardService.test.ts

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,36 @@
11
import {
22
bulkUpdateSubscriptions,
3+
cycleSubscriptionStatus,
34
deleteSubscription,
5+
filterAuditLog,
46
getAdminDashboardData,
7+
toggleMerchantStatus,
58
updateUserRole,
69
upsertSubscription,
10+
type AdminUserRecord,
11+
type MerchantRecord,
12+
type SubscriptionAdminRecord,
713
} from '../adminDashboardService';
814

15+
// ── Fixtures ──────────────────────────────────────────────────────────────
16+
17+
function makeMerchant(overrides: Partial<MerchantRecord> = {}): MerchantRecord {
18+
return { id: 'merch_1', name: 'Test', status: 'active', activePlans: 5, monthlyRevenue: 1000, ...overrides };
19+
}
20+
21+
function makeSub(overrides: Partial<SubscriptionAdminRecord> = {}): SubscriptionAdminRecord {
22+
return {
23+
id: 'sub_1', name: 'Pro Plan', merchantId: 'merch_1', merchantName: 'Test',
24+
amount: 29, currency: 'USD', status: 'active', ...overrides,
25+
};
26+
}
27+
28+
function makeUser(overrides: Partial<AdminUserRecord> = {}): AdminUserRecord {
29+
return { id: 'user_1', name: 'Test User', email: 'test@example.com', role: 'analyst', ...overrides };
30+
}
31+
32+
// ── getAdminDashboardData ────────────────────────────────────────────────
33+
934
describe('adminDashboardService', () => {
1035
it('returns seeded dashboard data', () => {
1136
const data = getAdminDashboardData('admin');
@@ -15,6 +40,13 @@ describe('adminDashboardService', () => {
1540
expect(data.auditLog.length).toBeGreaterThan(0);
1641
});
1742

43+
it('returns immutable copies — mutations do not affect source', () => {
44+
const a = getAdminDashboardData('admin');
45+
const b = getAdminDashboardData('admin');
46+
a.merchants[0].status = 'suspended';
47+
expect(b.merchants[0].status).not.toBe('suspended');
48+
});
49+
1850
it('supports bulk pause operations for elevated roles', () => {
1951
const data = getAdminDashboardData('analyst');
2052
const updated = bulkUpdateSubscriptions(data.subscriptions, ['sub_1'], 'analyst');
@@ -38,3 +70,160 @@ describe('adminDashboardService', () => {
3870
expect(nextUsers.find((user) => user.id === 'user_2')?.role).toBe('support');
3971
});
4072
});
73+
74+
// ── toggleMerchantStatus ─────────────────────────────────────────────────
75+
76+
describe('toggleMerchantStatus', () => {
77+
it('admin: suspends an active merchant', () => {
78+
expect(toggleMerchantStatus(makeMerchant({ status: 'active' }), 'admin').status).toBe('suspended');
79+
});
80+
81+
it('admin: activates a suspended merchant', () => {
82+
expect(toggleMerchantStatus(makeMerchant({ status: 'suspended' }), 'admin').status).toBe('active');
83+
});
84+
85+
it('non-admin roles: return merchant unchanged', () => {
86+
const m = makeMerchant({ status: 'active' });
87+
expect(toggleMerchantStatus(m, 'analyst').status).toBe('active');
88+
expect(toggleMerchantStatus(m, 'support').status).toBe('active');
89+
});
90+
91+
it('does not mutate the original object', () => {
92+
const m = makeMerchant({ status: 'active' });
93+
toggleMerchantStatus(m, 'admin');
94+
expect(m.status).toBe('active');
95+
});
96+
});
97+
98+
// ── upsertSubscription ───────────────────────────────────────────────────
99+
100+
describe('upsertSubscription', () => {
101+
it('admin: prepends a new draft subscription', () => {
102+
const result = upsertSubscription([makeSub()], 'admin');
103+
expect(result.length).toBe(2);
104+
expect(result[0].status).toBe('draft');
105+
});
106+
107+
it('analyst: can create a draft', () => {
108+
expect(upsertSubscription([makeSub()], 'analyst').length).toBe(2);
109+
});
110+
111+
it('support: cannot create subscriptions', () => {
112+
expect(upsertSubscription([makeSub()], 'support').length).toBe(1);
113+
});
114+
});
115+
116+
// ── cycleSubscriptionStatus ──────────────────────────────────────────────
117+
118+
describe('cycleSubscriptionStatus', () => {
119+
it('admin: draft → active', () => {
120+
expect(cycleSubscriptionStatus(makeSub({ status: 'draft' }), 'admin').status).toBe('active');
121+
});
122+
123+
it('admin: active → paused', () => {
124+
expect(cycleSubscriptionStatus(makeSub({ status: 'active' }), 'admin').status).toBe('paused');
125+
});
126+
127+
it('admin: paused → active', () => {
128+
expect(cycleSubscriptionStatus(makeSub({ status: 'paused' }), 'admin').status).toBe('active');
129+
});
130+
131+
it('support: returns subscription unchanged', () => {
132+
expect(cycleSubscriptionStatus(makeSub({ status: 'active' }), 'support').status).toBe('active');
133+
});
134+
});
135+
136+
// ── deleteSubscription ───────────────────────────────────────────────────
137+
138+
describe('deleteSubscription', () => {
139+
it('admin: removes the matching subscription', () => {
140+
const subs = [makeSub({ id: 'sub_a' }), makeSub({ id: 'sub_b' })];
141+
const result = deleteSubscription(subs, 'sub_a', 'admin');
142+
expect(result.length).toBe(1);
143+
expect(result[0].id).toBe('sub_b');
144+
});
145+
146+
it('non-admin: returns list unchanged', () => {
147+
const subs = [makeSub()];
148+
expect(deleteSubscription(subs, 'sub_1', 'analyst').length).toBe(1);
149+
expect(deleteSubscription(subs, 'sub_1', 'support').length).toBe(1);
150+
});
151+
});
152+
153+
// ── bulkUpdateSubscriptions ──────────────────────────────────────────────
154+
155+
describe('bulkUpdateSubscriptions', () => {
156+
it('pauses only selected subscriptions', () => {
157+
const subs = [makeSub({ id: 'a', status: 'active' }), makeSub({ id: 'b', status: 'active' })];
158+
const result = bulkUpdateSubscriptions(subs, ['a'], 'admin');
159+
expect(result.find((s) => s.id === 'a')!.status).toBe('paused');
160+
expect(result.find((s) => s.id === 'b')!.status).toBe('active');
161+
});
162+
163+
it('support: returns list unchanged', () => {
164+
const subs = [makeSub({ status: 'active' })];
165+
expect(bulkUpdateSubscriptions(subs, ['sub_1'], 'support')[0].status).toBe('active');
166+
});
167+
168+
it('empty selection: no changes', () => {
169+
const subs = [makeSub({ status: 'active' })];
170+
expect(bulkUpdateSubscriptions(subs, [], 'admin')[0].status).toBe('active');
171+
});
172+
});
173+
174+
// ── updateUserRole ───────────────────────────────────────────────────────
175+
176+
describe('updateUserRole', () => {
177+
it('admin: viewer → analyst', () => {
178+
expect(updateUserRole([makeUser({ id: 'u1', role: 'viewer' })], 'u1', 'admin')[0].role).toBe('analyst');
179+
});
180+
181+
it('admin: analyst → support', () => {
182+
expect(updateUserRole([makeUser({ id: 'u1', role: 'analyst' })], 'u1', 'admin')[0].role).toBe('support');
183+
});
184+
185+
it('admin: support → viewer', () => {
186+
expect(updateUserRole([makeUser({ id: 'u1', role: 'support' })], 'u1', 'admin')[0].role).toBe('viewer');
187+
});
188+
189+
it('non-admin: returns list unchanged', () => {
190+
const users = [makeUser({ id: 'u1', role: 'analyst' })];
191+
expect(updateUserRole(users, 'u1', 'analyst')[0].role).toBe('analyst');
192+
expect(updateUserRole(users, 'u1', 'support')[0].role).toBe('analyst');
193+
});
194+
});
195+
196+
// ── filterAuditLog ───────────────────────────────────────────────────────
197+
198+
describe('filterAuditLog', () => {
199+
const { auditLog } = getAdminDashboardData('admin');
200+
201+
it('filters by resourceType', () => {
202+
const results = filterAuditLog(auditLog, { resourceType: 'merchant' });
203+
expect(results.length).toBeGreaterThan(0);
204+
expect(results.every((e) => e.resourceType === 'merchant')).toBe(true);
205+
});
206+
207+
it('filters by action', () => {
208+
const results = filterAuditLog(auditLog, { action: 'subscription.paused' });
209+
expect(results.every((e) => e.action === 'subscription.paused')).toBe(true);
210+
});
211+
212+
it('filters by actorId', () => {
213+
const results = filterAuditLog(auditLog, { actorId: 'user_1' });
214+
expect(results.every((e) => e.actorId === 'user_1')).toBe(true);
215+
});
216+
217+
it('returns all events when no filter criteria given', () => {
218+
expect(filterAuditLog(auditLog, {}).length).toBe(auditLog.length);
219+
});
220+
221+
it('returns empty array when nothing matches', () => {
222+
expect(filterAuditLog(auditLog, { actorId: 'nobody' }).length).toBe(0);
223+
});
224+
225+
it('applies multiple criteria as AND', () => {
226+
const results = filterAuditLog(auditLog, { resourceType: 'subscription', actorId: 'user_2' });
227+
expect(results.every((e) => e.resourceType === 'subscription' && e.actorId === 'user_2')).toBe(true);
228+
});
229+
});

src/services/adminDashboardService.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,3 +265,21 @@ export function updateUserRole(
265265
: user
266266
);
267267
}
268+
269+
export interface AuditLogFilter {
270+
resourceType?: string;
271+
action?: string;
272+
actorId?: string;
273+
}
274+
275+
export function filterAuditLog(
276+
log: AuditEvent[],
277+
filter: AuditLogFilter
278+
): AuditEvent[] {
279+
return log.filter((event) => {
280+
if (filter.resourceType !== undefined && event.resourceType !== filter.resourceType) return false;
281+
if (filter.action !== undefined && event.action !== filter.action) return false;
282+
if (filter.actorId !== undefined && event.actorId !== filter.actorId) return false;
283+
return true;
284+
});
285+
}

0 commit comments

Comments
 (0)