// FE-MOB-AUDIT-001 to FE-MOB-AUDIT-012
import React from 'react';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { render, screen, waitFor } from '../../../helpers/render';
import { server } from '../../../helpers/msw/server';
import { resetAllStores } from '../../../helpers/store';
import MAdminAuditLogPanel from '../../../../src/mobile/screens/admin/MAdminAuditLogPanel';
const ENTRY_1 = {
id: 1,
created_at: '2025-06-01T10:30:00Z',
user_id: 5,
username: 'alice',
user_email: 'alice@example.com',
action: 'trip.create',
resource: '/trips/42',
details: { title: 'Test' },
ip: '127.0.0.1',
};
const ENTRY_2 = {
id: 2,
created_at: '2025-06-02T11:00:00Z',
user_id: 6,
username: 'bob',
user_email: 'bob@example.com',
action: 'trip.delete',
resource: null,
details: null,
ip: null,
};
beforeEach(() => {
resetAllStores();
});
afterEach(() => {
server.resetHandlers();
});
describe('MAdminAuditLogPanel', () => {
it('FE-MOB-AUDIT-001: header, subtitle and the loading placeholder render on mount', () => {
server.use(
http.get('/api/admin/audit-log', async () => {
await new Promise(() => {});
return HttpResponse.json({ entries: [], total: 0 });
}),
);
render();
expect(screen.getByText('Audit')).toBeInTheDocument();
expect(
screen.getByText('Security-sensitive and administration events (backups, users, MFA, settings).'),
).toBeInTheDocument();
expect(screen.getByText('Loading...')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Refresh' })).toBeDisabled();
});
it('FE-MOB-AUDIT-002: empty state and a zeroed counter when there is nothing to show', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [], total: 0 })));
render();
await screen.findByText('No audit entries yet.');
expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument();
expect(screen.queryByText('Load more')).not.toBeInTheDocument();
});
it('FE-MOB-AUDIT-003: an entry card shows action, user, resource, ip and details', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render();
await screen.findByText('trip.create');
expect(screen.getByText('User')).toBeInTheDocument();
expect(screen.getByText('Resource')).toBeInTheDocument();
expect(screen.getByText('IP')).toBeInTheDocument();
expect(screen.getByText('Details')).toBeInTheDocument();
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('/trips/42')).toBeInTheDocument();
expect(screen.getByText('127.0.0.1')).toBeInTheDocument();
expect(screen.getByText('{"title":"Test"}')).toBeInTheDocument();
expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-004: the timestamp is rendered in the server timezone', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render();
await screen.findByText('trip.create');
expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-005: timestamps without a Z suffix are treated as UTC', async () => {
const naive = { ...ENTRY_1, created_at: '2025-06-01T10:30:00' };
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [naive], total: 1 })));
render();
await screen.findByText('trip.create');
expect(screen.getByText('6/1/25, 10:30:00 AM')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-006: an unusable timezone falls back to the raw timestamp', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_1], total: 1 })));
render();
await screen.findByText('trip.create');
expect(screen.getByText('2025-06-01T10:30:00Z')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-007: the user label falls back through email, id and dash', async () => {
const entries = [
{ ...ENTRY_1, id: 10, action: 'a.username' },
{ ...ENTRY_1, id: 11, username: null, action: 'a.email' },
{ ...ENTRY_1, id: 12, username: null, user_email: null, user_id: 7, action: 'a.id' },
{ ...ENTRY_1, id: 13, username: null, user_email: null, user_id: null, action: 'a.none' },
];
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ entries, total: 4 })));
render();
await screen.findByText('a.username');
expect(screen.getByText('alice')).toBeInTheDocument();
expect(screen.getByText('alice@example.com')).toBeInTheDocument();
expect(screen.getByText('#7')).toBeInTheDocument();
expect(screen.getAllByText('—')).toHaveLength(1);
});
it('FE-MOB-AUDIT-008: null resource, ip and details render as a dash', async () => {
const emptyDetails = { ...ENTRY_1, id: 21, action: 'a.emptyobj', details: {} };
server.use(
http.get('/api/admin/audit-log', () => HttpResponse.json({ entries: [ENTRY_2, emptyDetails], total: 2 })),
);
render();
await screen.findByText('trip.delete');
// ENTRY_2 contributes three dashes, the empty details object a fourth
expect(screen.getAllByText('—')).toHaveLength(4);
});
it('FE-MOB-AUDIT-009: Load more appends the next page and disappears when complete', async () => {
const user = userEvent.setup();
const offsets: string[] = [];
server.use(
http.get('/api/admin/audit-log', ({ request }) => {
const offset = new URL(request.url).searchParams.get('offset') ?? '';
offsets.push(offset);
return offset === '0'
? HttpResponse.json({ entries: [ENTRY_1], total: 2 })
: HttpResponse.json({ entries: [ENTRY_2], total: 2 });
}),
);
render();
await screen.findByText('trip.create');
await user.click(screen.getByRole('button', { name: 'Load more' }));
await screen.findByText('trip.delete');
expect(screen.getByText('trip.create')).toBeInTheDocument();
expect(screen.getByText('2 loaded · 2 total')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Load more' })).not.toBeInTheDocument();
expect(offsets).toEqual(['0', '100']);
});
it('FE-MOB-AUDIT-010: a failing Load more keeps the entries already loaded', async () => {
const user = userEvent.setup();
let call = 0;
server.use(
http.get('/api/admin/audit-log', () => {
call += 1;
return call === 1
? HttpResponse.json({ entries: [ENTRY_1], total: 5 })
: HttpResponse.json({ error: 'boom' }, { status: 500 });
}),
);
render();
await screen.findByText('trip.create');
await user.click(screen.getByRole('button', { name: 'Load more' }));
await waitFor(() => expect(screen.getByRole('button', { name: 'Load more' })).not.toBeDisabled());
expect(screen.getByText('trip.create')).toBeInTheDocument();
expect(screen.getByText('1 loaded · 5 total')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-011: Refresh throws away the appended pages', async () => {
const user = userEvent.setup();
let call = 0;
server.use(
http.get('/api/admin/audit-log', () => {
call += 1;
if (call === 1) return HttpResponse.json({ entries: [ENTRY_1], total: 2 });
if (call === 2) return HttpResponse.json({ entries: [ENTRY_2], total: 2 });
return HttpResponse.json({ entries: [{ ...ENTRY_1, id: 99, action: 'after.refresh' }], total: 1 });
}),
);
render();
await screen.findByText('trip.create');
await user.click(screen.getByRole('button', { name: 'Load more' }));
await screen.findByText('trip.delete');
await user.click(screen.getByRole('button', { name: 'Refresh' }));
await screen.findByText('after.refresh');
expect(screen.queryByText('trip.create')).not.toBeInTheDocument();
expect(screen.queryByText('trip.delete')).not.toBeInTheDocument();
expect(screen.getByText('1 loaded · 1 total')).toBeInTheDocument();
});
it('FE-MOB-AUDIT-012: a failing first page falls back to the empty state', async () => {
server.use(http.get('/api/admin/audit-log', () => HttpResponse.json({ error: 'boom' }, { status: 500 })));
render();
await screen.findByText('No audit entries yet.');
expect(screen.getByText('0 loaded · 0 total')).toBeInTheDocument();
});
});