Skip to content

Commit e7c5305

Browse files
Merge pull request #132 from iblai/feat/skills/1914
fix(skills): audit page for analytics
2 parents 4494716 + af2278f commit e7c5305

9 files changed

Lines changed: 312 additions & 8 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { render, screen } from '@testing-library/react';
3+
import '@testing-library/jest-dom';
4+
5+
vi.mock('@/components/tenant-redirect', () => ({
6+
TenantRedirect: () => <div data-testid="tenant-redirect" />,
7+
}));
8+
9+
import Page from '../page';
10+
11+
describe('legacy tenant redirect page', () => {
12+
it('renders the TenantRedirect stub', () => {
13+
render(<Page />);
14+
expect(screen.getByTestId('tenant-redirect')).toBeInTheDocument();
15+
});
16+
});

app/analytics/audit/page.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { TenantRedirect } from '@/components/tenant-redirect';
2+
3+
export default function Page() {
4+
return <TenantRedirect />;
5+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, it, expect, vi, beforeEach } from 'vitest';
2+
import { render } from '@testing-library/react';
3+
import AuditPage from '../page';
4+
import { AnalyticsAuditLogStats } from '@iblai/iblai-js/web-containers';
5+
import '@testing-library/jest-dom';
6+
7+
// Mock getTenant and getUserName from helpers
8+
const mockGetUserName = vi.fn((): string | null => 'test-user');
9+
10+
vi.mock('@/utils/helpers', () => ({
11+
getTenant: vi.fn(() => 'test-tenant'),
12+
getUserName: () => mockGetUserName(),
13+
}));
14+
15+
// Mock the web-containers module
16+
vi.mock('@iblai/iblai-js/web-containers', () => ({
17+
AnalyticsAuditLogStats: vi.fn(({ tenantKey, mentorId, userId, selectedMentorId }) => (
18+
<div data-testid="analytics-audit-log-stats">
19+
<span data-testid="tenant-key">{tenantKey}</span>
20+
<span data-testid="mentor-id">{mentorId}</span>
21+
<span data-testid="user-id">{userId}</span>
22+
<span data-testid="selected-mentor-id">{selectedMentorId}</span>
23+
</div>
24+
)),
25+
}));
26+
27+
describe('AuditPage', () => {
28+
beforeEach(() => {
29+
vi.clearAllMocks();
30+
mockGetUserName.mockReturnValue('test-user');
31+
});
32+
33+
it('renders without crashing', () => {
34+
const { container } = render(<AuditPage />);
35+
expect(container).toBeTruthy();
36+
});
37+
38+
it('renders the AnalyticsAuditLogStats component', () => {
39+
const { getByTestId } = render(<AuditPage />);
40+
expect(getByTestId('analytics-audit-log-stats')).toBeInTheDocument();
41+
});
42+
43+
it('passes the correct tenantKey from getTenant', () => {
44+
const { getByTestId } = render(<AuditPage />);
45+
expect(getByTestId('tenant-key')).toHaveTextContent('test-tenant');
46+
});
47+
48+
it('passes empty string for mentorId and selectedMentorId (Skills app does not use mentor)', () => {
49+
render(<AuditPage />);
50+
expect(AnalyticsAuditLogStats).toHaveBeenCalledWith(
51+
expect.objectContaining({ mentorId: '', selectedMentorId: '' }),
52+
undefined,
53+
);
54+
});
55+
56+
it('passes the username from getUserName as userId', () => {
57+
render(<AuditPage />);
58+
expect(AnalyticsAuditLogStats).toHaveBeenCalledWith(
59+
expect.objectContaining({ userId: 'test-user' }),
60+
undefined,
61+
);
62+
});
63+
64+
it('falls back to empty string when username is null', () => {
65+
mockGetUserName.mockReturnValue(null);
66+
67+
render(<AuditPage />);
68+
expect(AnalyticsAuditLogStats).toHaveBeenCalledWith(
69+
expect.objectContaining({ userId: '' }),
70+
undefined,
71+
);
72+
});
73+
});
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
'use client';
2+
3+
import { useTenantParam } from '@/hooks/use-tenant-param';
4+
import { AnalyticsAuditLogStats } from '@iblai/iblai-js/web-containers';
5+
import { getUserName } from '@/utils/helpers';
6+
7+
export default function AuditPage() {
8+
const tenant = useTenantParam();
9+
10+
// For Skills app, we'll use the audit log stats component
11+
// without mentor-specific parameters
12+
return (
13+
<AnalyticsAuditLogStats
14+
tenantKey={tenant}
15+
mentorId={''}
16+
userId={getUserName() || ''}
17+
selectedMentorId={''}
18+
/>
19+
);
20+
}

e2e/COVERAGE.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# SkillsAI E2E Coverage — User Journey Checklist
22

3-
> Last updated: 2026-06-02 | 237 checkpoints | 31 journeys | 100% covered
3+
> Last updated: 2026-06-12 | 225 checkpoints | 32 journeys | 100% covered
44
55
## How This Works
66

@@ -455,4 +455,14 @@ When adding a new page or modifying an existing user flow:
455455

456456
---
457457

458+
## Journey 33: Analytics Audit (3 checkpoints) — `journeys/33-analytics-audit.spec.ts`
459+
460+
**Source files:** `app/analytics/audit/page.tsx`, `app/platform/[tenant]/analytics/audit/page.tsx`
461+
462+
- [x] Audit tab routes to /analytics/audit and the audit log view renders _(admin only)_
463+
- [x] Audit log filters (user search, action filter) are visible
464+
- [x] Audit log table (USER/ACTION/TIME) or empty state is visible
465+
466+
---
467+
458468
> **Note:** `auth.setup.ts` runs before all journeys to set up authentication. It is not a user journey.

e2e/coverage.json

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
{
22
"version": 2,
3-
"lastUpdated": "2026-06-02",
3+
"lastUpdated": "2026-06-12",
44
"summary": {
5-
"totalCheckpoints": 222,
6-
"coveredCheckpoints": 222,
5+
"totalCheckpoints": 225,
6+
"coveredCheckpoints": 225,
77
"percent": 100,
8-
"totalJourneys": 31
8+
"totalJourneys": 32
99
},
1010
"journeys": [
1111
{
@@ -36,7 +36,12 @@
3636
"id": "onboarding-first-time-user",
3737
"name": "Onboarding — First-Time User",
3838
"spec": "02-onboarding-first-time-user.spec.ts",
39-
"sourceFiles": ["app/start/page.tsx", "app/home/page.tsx", "app/platform/[tenant]/start/page.tsx", "app/platform/[tenant]/home/page.tsx"],
39+
"sourceFiles": [
40+
"app/start/page.tsx",
41+
"app/home/page.tsx",
42+
"app/platform/[tenant]/start/page.tsx",
43+
"app/platform/[tenant]/home/page.tsx"
44+
],
4045
"checkpoints": [
4146
{
4247
"id": "onb-01",
@@ -123,7 +128,11 @@
123128
"id": "course-about-and-configuration",
124129
"name": "Course About & Configuration",
125130
"spec": "04-course-about-and-configuration.spec.ts",
126-
"sourceFiles": ["app/courses/[course_id]/page.tsx", "app/platform/[tenant]/courses/[course_id]/page.tsx", "components/course-access-guard.tsx"],
131+
"sourceFiles": [
132+
"app/courses/[course_id]/page.tsx",
133+
"app/platform/[tenant]/courses/[course_id]/page.tsx",
134+
"components/course-access-guard.tsx"
135+
],
127136
"checkpoints": [
128137
{
129138
"id": "course-01",
@@ -1172,6 +1181,32 @@
11721181
"status": "covered"
11731182
}
11741183
]
1184+
},
1185+
{
1186+
"id": "analytics-audit",
1187+
"name": "Analytics Audit",
1188+
"spec": "33-analytics-audit.spec.ts",
1189+
"sourceFiles": [
1190+
"app/analytics/audit/page.tsx",
1191+
"app/platform/[tenant]/analytics/audit/page.tsx"
1192+
],
1193+
"checkpoints": [
1194+
{
1195+
"id": "aud-01",
1196+
"description": "Audit tab routes to /analytics/audit and the audit log view renders",
1197+
"status": "covered"
1198+
},
1199+
{
1200+
"id": "aud-02",
1201+
"description": "Audit log filters (user search, action filter) are visible",
1202+
"status": "covered"
1203+
},
1204+
{
1205+
"id": "aud-03",
1206+
"description": "Audit log table (USER/ACTION/TIME) or empty state is visible",
1207+
"status": "covered"
1208+
}
1209+
]
11751210
}
11761211
]
11771212
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { test, expect } from '@playwright/test';
2+
import { logger } from '@iblai/iblai-js/playwright';
3+
import { waitForAppShell, gotoTenantPage } from '../utils/navigation';
4+
5+
test.describe('Journey 33: Analytics Audit', () => {
6+
test.setTimeout(200_000);
7+
8+
test.beforeEach(async ({ page }) => {
9+
await gotoTenantPage(page, 'home', { timeout: 120_000 });
10+
await waitForAppShell(page);
11+
12+
// Admin gate: check if AI Analytics link is visible
13+
const analyticsLink = page.getByRole('link', { name: /ai analytics|analytics/i });
14+
const isAdmin = await analyticsLink.isVisible({ timeout: 120_000 }).catch(() => false);
15+
if (!isAdmin) {
16+
test.skip(true, 'Analytics requires admin access — AI Analytics link not visible');
17+
return;
18+
}
19+
20+
logger.info('Navigating to Analytics');
21+
await analyticsLink.click();
22+
await page.waitForURL((url) => url.href.includes('/analytics'), { timeout: 30_000 });
23+
});
24+
25+
/**
26+
* Clicks the Audit tab and returns false (skipping the test) when the tab is
27+
* not visible. The audit log view itself renders one of: the filters + table,
28+
* the empty state, or a permission notice for non-authorized users.
29+
*/
30+
async function openAuditTab(page: import('@playwright/test').Page): Promise<boolean> {
31+
const auditTab = page.getByRole('tab', { name: 'Audit', exact: true });
32+
const hasAuditTab = await auditTab.isVisible({ timeout: 120_000 }).catch(() => false);
33+
if (!hasAuditTab) {
34+
return false;
35+
}
36+
37+
await auditTab.click();
38+
await expect(auditTab).toHaveAttribute('data-state', 'active', { timeout: 30_000 });
39+
return true;
40+
}
41+
42+
test('CP-1: audit tab routes to the audit log view', async ({ page }) => {
43+
logger.info('CP-1: navigating to Audit tab');
44+
const opened = await openAuditTab(page);
45+
if (!opened) {
46+
test.skip(true, 'Audit analytics tab not visible');
47+
return;
48+
}
49+
50+
await page.waitForURL((url) => url.pathname.endsWith('/analytics/audit'), {
51+
timeout: 30_000,
52+
});
53+
54+
logger.info('CP-1: checking the audit log view rendered');
55+
// After loading completes, one of these will appear:
56+
// - the USER/ACTION/TIME table (has results)
57+
// - "No audit log entries found for this tenant." (empty)
58+
// - "You do not have permission to view audit logs." (403)
59+
const loadedIndicator = page
60+
.getByRole('columnheader', { name: 'USER', exact: true })
61+
.or(page.getByText('No audit log entries found for this tenant.'))
62+
.or(page.getByText('You do not have permission to view audit logs.'));
63+
64+
await expect(loadedIndicator.first()).toBeVisible({ timeout: 120_000 });
65+
logger.info('CP-1: audit log view rendered — table, empty state, or permission notice');
66+
});
67+
68+
test('CP-2: audit log filters are visible', async ({ page }) => {
69+
logger.info('CP-2: navigating to Audit tab');
70+
const opened = await openAuditTab(page);
71+
if (!opened) {
72+
test.skip(true, 'Audit analytics tab not visible');
73+
return;
74+
}
75+
76+
const permissionNotice = page.getByText('You do not have permission to view audit logs.');
77+
const hasNoPermission = await permissionNotice.isVisible({ timeout: 5_000 }).catch(() => false);
78+
if (hasNoPermission) {
79+
test.skip(true, 'User lacks permission to view audit logs — filters not rendered');
80+
return;
81+
}
82+
83+
logger.info('CP-2: checking user search and action filters');
84+
const userSearch = page.getByRole('button', { name: 'Search for User' });
85+
const actionFilter = page.getByText('All Actions');
86+
87+
const hasUserSearch = await userSearch.isVisible({ timeout: 120_000 }).catch(() => false);
88+
const hasActionFilter = await actionFilter
89+
.first()
90+
.isVisible({ timeout: 120_000 })
91+
.catch(() => false);
92+
93+
logger.info(`CP-2: userSearch=${hasUserSearch} actionFilter=${hasActionFilter}`);
94+
expect(hasUserSearch, 'User search filter should be visible').toBe(true);
95+
expect(hasActionFilter, 'Action filter should be visible').toBe(true);
96+
});
97+
98+
test('CP-3: audit log table or empty state is visible', async ({ page }) => {
99+
logger.info('CP-3: navigating to Audit tab');
100+
const opened = await openAuditTab(page);
101+
if (!opened) {
102+
test.skip(true, 'Audit analytics tab not visible');
103+
return;
104+
}
105+
106+
const permissionNotice = page.getByText('You do not have permission to view audit logs.');
107+
const hasNoPermission = await permissionNotice.isVisible({ timeout: 5_000 }).catch(() => false);
108+
if (hasNoPermission) {
109+
test.skip(true, 'User lacks permission to view audit logs — table not rendered');
110+
return;
111+
}
112+
113+
logger.info('CP-3: checking for audit entries table or empty state');
114+
const tableHeader = page.getByRole('columnheader', { name: 'USER', exact: true });
115+
const emptyState = page.getByText('No audit log entries found for this tenant.');
116+
117+
await expect(tableHeader.or(emptyState).first()).toBeVisible({ timeout: 120_000 });
118+
119+
const hasTable = await tableHeader.isVisible({ timeout: 1_000 }).catch(() => false);
120+
if (hasTable) {
121+
logger.info('CP-3: audit table rendered — verifying ACTION and TIME headers');
122+
await expect(page.getByRole('columnheader', { name: 'ACTION', exact: true })).toBeVisible({
123+
timeout: 30_000,
124+
});
125+
await expect(page.getByRole('columnheader', { name: 'TIME', exact: true })).toBeVisible({
126+
timeout: 30_000,
127+
});
128+
} else {
129+
logger.info('CP-3: empty state rendered — no audit log entries for this tenant');
130+
}
131+
});
132+
});

lib/__tests__/store.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,11 @@ vi.mock('@iblai/iblai-js/data-layer', () => ({
123123
reducer: (state = {}) => state,
124124
middleware: createMockMiddleware(),
125125
},
126+
auditLogsApiSlice: {
127+
reducerPath: 'auditLogsApiSlice',
128+
reducer: (state = {}) => state,
129+
middleware: createMockMiddleware(),
130+
},
126131
}));
127132

128133
describe('store', () => {
@@ -158,6 +163,7 @@ describe('store', () => {
158163
expect(state).toHaveProperty('CoreSlice');
159164
expect(state).toHaveProperty('rbacSlice');
160165
expect(state).toHaveProperty('StudioSlice');
166+
expect(state).toHaveProperty('auditLogsApiSlice');
161167
});
162168

163169
it('includes skillsReducer from data-layer', async () => {

lib/store.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { PlatformSlice } from '@/services/platform';
88
import { CourseMetadataSlice } from '@/services/course-metadata';
99
import { CatalogSlice } from '@/services/catalog';
1010
// @ts-ignore
11-
import { skillsMiddleware, skillsReducer, authApiSlice } from '@iblai/iblai-js/data-layer';
11+
import {
12+
skillsMiddleware,
13+
skillsReducer,
14+
authApiSlice,
15+
auditLogsApiSlice,
16+
} from '@iblai/iblai-js/data-layer';
1217
// @ts-ignore
1318
import { monetizationSlice } from '@iblai/iblai-js/web-utils';
1419
import { CareerSlice } from '@/services/career';
@@ -39,6 +44,7 @@ export const store = configureStore({
3944
[StudioSlice.reducerPath]: StudioSlice.reducer,
4045
monetization: monetizationSlice.reducer,
4146
[authApiSlice.reducerPath]: authApiSlice.reducer,
47+
[auditLogsApiSlice.reducerPath]: auditLogsApiSlice.reducer,
4248
...skillsReducer,
4349
},
4450
middleware: (getDefaultMiddleware) => {
@@ -57,6 +63,7 @@ export const store = configureStore({
5763
CoreSlice.middleware,
5864
StudioSlice.middleware,
5965
authApiSlice.middleware,
66+
auditLogsApiSlice.middleware,
6067
...skillsMiddleware,
6168
];
6269
return getDefaultMiddleware().concat(additionalMiddleware);

0 commit comments

Comments
 (0)