From 66318edf77ef52bde18980e015041da2f39f653d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:33:51 +0000 Subject: [PATCH 1/4] Add "Enroll student" link to admin sidebar nav The manual enrollment flow at /admin/enroll (grant a pricing tier without a PayMongo payment) existed but was only reachable via direct URL or from a student's detail page, not from the main admin nav. --- src/components/ui/Icon.tsx | 5 ++++- src/components/ui/NavSidebar.tsx | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/components/ui/Icon.tsx b/src/components/ui/Icon.tsx index 9918e67..0bf97e3 100644 --- a/src/components/ui/Icon.tsx +++ b/src/components/ui/Icon.tsx @@ -46,6 +46,7 @@ import { Download, Video, Circle, + UserPlus, } from '@phosphor-icons/react/dist/ssr'; import clsx from 'clsx'; import styles from './Icon.module.css'; @@ -113,7 +114,8 @@ export type PhosphorIconName = | 'ArrowRight' | 'Download' | 'Video' - | 'Circle'; + | 'Circle' + | 'UserPlus'; const ICON_MAP: Record> = { House, @@ -149,6 +151,7 @@ const ICON_MAP: Record> Download, Video, Circle, + UserPlus, }; export function Icon({ diff --git a/src/components/ui/NavSidebar.tsx b/src/components/ui/NavSidebar.tsx index 93d8fab..ae52677 100644 --- a/src/components/ui/NavSidebar.tsx +++ b/src/components/ui/NavSidebar.tsx @@ -15,6 +15,7 @@ export interface NavItem { const ADMIN_NAV_ITEMS: NavItem[] = [ { href: '/admin', label: 'Dashboard', icon: 'House' }, { href: '/admin/users', label: 'Users', icon: 'User' }, + { href: '/admin/enroll', label: 'Enroll student', icon: 'UserPlus' }, { href: '/admin/courses', label: 'Courses', icon: 'BookOpen' }, { href: '/admin/refunds', label: 'Refunds', icon: 'Receipt' }, { href: '/admin/tool-scenarios', label: 'Tool scenarios', icon: 'List' }, From 9927bcecfb1ad3ca33fd95ac654ea4ba2b891345 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:39:33 +0000 Subject: [PATCH 2/4] Add optional payment record to manual enrollment for bookkeeping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /admin/enroll now lets the admin note how a student paid outside the platform (method, amount, free-text reference) when granting a tier. grantManualEnrollment() creates a single COMPLETED Payment row for the grant (not tied to one course enrollment, since a tier can bundle several) so it shows up under the student's Payments in the admin panel. Recording payment is optional — comp/free grants still work exactly as before with no Payment row. Also fixed the Payments badge on the student detail page, which checked payment.status against 'PAID', a value the app never uses (the real enum value is 'COMPLETED'), so completed payments always rendered as the default gray badge. --- src/app/actions/admin-enroll.ts | 21 +++++ src/app/admin/enroll/EnrollForm.tsx | 108 ++++++++++++++++++++++++- src/app/admin/enroll/enroll.module.css | 19 +++++ src/app/admin/enroll/page.tsx | 1 + src/app/admin/users/[id]/page.tsx | 7 +- src/lib/enrollment.ts | 33 +++++++- 6 files changed, 185 insertions(+), 4 deletions(-) diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts index 6930b66..ce4f408 100644 --- a/src/app/actions/admin-enroll.ts +++ b/src/app/actions/admin-enroll.ts @@ -14,12 +14,22 @@ import { revalidatePath } from 'next/cache'; import { requireAdmin } from '@/lib/auth'; import { auditLog } from '@/lib/admin-audit'; import { grantManualEnrollment } from '@/lib/enrollment'; +import { PaymentMethodValues } from '@/lib/enums'; import type { ActionResult } from '@/lib/validation'; const manualEnrollSchema = z.object({ email: z.string().trim().toLowerCase().email('Enter a valid email address.'), name: z.string().trim().max(100).optional(), pricingTierId: z.string().min(1, 'Pick a pricing tier.'), + payment: z + .object({ + method: z.enum(PaymentMethodValues as [string, ...string[]], { + message: 'Pick a payment method.', + }), + amountPhp: z.number().int().positive('Enter the amount paid.'), + reference: z.string().trim().max(200).optional(), + }) + .optional(), }); export interface ManualEnrollActionData { @@ -29,6 +39,7 @@ export interface ManualEnrollActionData { tierName: string; enrolledCount: number; alreadyEnrolledCount: number; + paymentRecorded: boolean; } export async function manualEnrollAction( @@ -49,12 +60,21 @@ export async function manualEnrollAction( email: parsed.data.email, name: parsed.data.name || null, pricingTierId: parsed.data.pricingTierId, + payment: parsed.data.payment, }); await auditLog({ action: 'MANUAL_ENROLL', entityType: 'User', entityId: result.userId, + metadata: parsed.data.payment + ? { + tier: result.tierName, + paymentMethod: parsed.data.payment.method, + amountPhp: parsed.data.payment.amountPhp, + reference: parsed.data.payment.reference || undefined, + } + : undefined, }); revalidatePath('/admin/users'); revalidatePath(`/admin/users/${result.userId}`); @@ -77,6 +97,7 @@ export async function manualEnrollAction( tierName: result.tierName, enrolledCount: result.enrolledCourseIds.length, alreadyEnrolledCount: result.alreadyEnrolledCourseIds.length, + paymentRecorded: result.paymentRecorded, }, }; } catch (err) { diff --git a/src/app/admin/enroll/EnrollForm.tsx b/src/app/admin/enroll/EnrollForm.tsx index 5779f46..6685ce3 100644 --- a/src/app/admin/enroll/EnrollForm.tsx +++ b/src/app/admin/enroll/EnrollForm.tsx @@ -6,15 +6,29 @@ import { manualEnrollAction, type ManualEnrollActionData, } from '@/app/actions/admin-enroll'; +import { PaymentMethod, PaymentMethodValues } from '@/lib/enums'; import styles from './enroll.module.css'; interface TierOption { id: string; name: string; priceLabel: string; + /** Centavos. */ + pricePhp: number; courseCount: number; } +const PAYMENT_METHOD_LABELS: Record = { + [PaymentMethod.GCASH]: 'GCash', + [PaymentMethod.MAYA]: 'Maya', + [PaymentMethod.GRABPAY]: 'GrabPay', + [PaymentMethod.CREDIT_CARD]: 'Credit card', + [PaymentMethod.DEBIT_CARD]: 'Debit card', + [PaymentMethod.BANK_TRANSFER]: 'Bank transfer', + [PaymentMethod.OTC]: 'Over-the-counter', + [PaymentMethod.OTHER]: 'Other', +}; + export function EnrollForm({ defaultEmail, tiers, @@ -26,10 +40,33 @@ export function EnrollForm({ const [email, setEmail] = useState(defaultEmail); const [name, setName] = useState(''); const [pricingTierId, setPricingTierId] = useState(tiers[0]?.id ?? ''); + const [recordPayment, setRecordPayment] = useState(false); + const [paymentMethod, setPaymentMethod] = useState( + PaymentMethodValues[0] ?? '', + ); + const [amountPhpInput, setAmountPhpInput] = useState(''); + const [reference, setReference] = useState(''); const [error, setError] = useState(null); const [result, setResult] = useState(null); const [copied, setCopied] = useState(false); + function selectedTierPriceLabel(tierId: string): string { + const tier = tiers.find((t) => t.id === tierId); + return tier ? (tier.pricePhp / 100).toFixed(2) : ''; + } + + function handleTierChange(tierId: string) { + setPricingTierId(tierId); + if (recordPayment) setAmountPhpInput(selectedTierPriceLabel(tierId)); + } + + function handleRecordPaymentToggle(checked: boolean) { + setRecordPayment(checked); + if (checked && !amountPhpInput) { + setAmountPhpInput(selectedTierPriceLabel(pricingTierId)); + } + } + function handleSubmit() { setError(null); setResult(null); @@ -38,11 +75,27 @@ export function EnrollForm({ setError('Enter the student email.'); return; } + + let payment: { method: string; amountPhp: number; reference?: string } | undefined; + if (recordPayment) { + const pesos = Number(amountPhpInput); + if (!amountPhpInput || Number.isNaN(pesos) || pesos <= 0) { + setError('Enter the amount paid.'); + return; + } + payment = { + method: paymentMethod, + amountPhp: Math.round(pesos * 100), + reference: reference.trim() || undefined, + }; + } + startTransition(async () => { const res = await manualEnrollAction({ email, name: name.trim() || undefined, pricingTierId, + payment, }); if (!res.success) { setError(res.error); @@ -92,7 +145,7 @@ export function EnrollForm({ Pricing tier + + + {recordPayment && ( +
+ + + + + +
+ )} + @@ -120,6 +225,7 @@ export function EnrollForm({ {result.alreadyEnrolledCount > 0 ? ` (${result.alreadyEnrolledCount} already active)` : ''} + {result.paymentRecorded ? ' · payment recorded' : ''}

{result.claimUrl ? ( diff --git a/src/app/admin/enroll/enroll.module.css b/src/app/admin/enroll/enroll.module.css index 8596e45..209d329 100644 --- a/src/app/admin/enroll/enroll.module.css +++ b/src/app/admin/enroll/enroll.module.css @@ -42,6 +42,25 @@ } .select { cursor: pointer; } +.checkboxRow { + display: flex; + align-items: center; + gap: var(--space-2); + font-size: var(--text-sm); + color: var(--color-text); + cursor: pointer; +} + +.paymentFields { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg); +} + .error { color: var(--color-danger, #dc3545); font-size: var(--text-sm); diff --git a/src/app/admin/enroll/page.tsx b/src/app/admin/enroll/page.tsx index 746b271..e453f2f 100644 --- a/src/app/admin/enroll/page.tsx +++ b/src/app/admin/enroll/page.tsx @@ -43,6 +43,7 @@ export default async function AdminEnrollPage({ id: t.id, name: t.name, priceLabel: formatPhp(t.pricePhp), + pricePhp: t.pricePhp, courseCount: t.courses.length, }))} /> diff --git a/src/app/admin/users/[id]/page.tsx b/src/app/admin/users/[id]/page.tsx index ba4bc96..b3a5a5a 100644 --- a/src/app/admin/users/[id]/page.tsx +++ b/src/app/admin/users/[id]/page.tsx @@ -172,13 +172,16 @@ export default async function UserDetailPage({
{formatPhp(p.amountPhp)}{' '} - + {p.status}
- {p.pricingTier?.name} · {formatDate(p.createdAt)} + {p.pricingTier?.name} · {p.method} · {formatDate(p.createdAt)}
+ {p.metadata && ( +
{p.metadata}
+ )}
))} diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts index f210300..b7ff4ba 100644 --- a/src/lib/enrollment.ts +++ b/src/lib/enrollment.ts @@ -12,7 +12,7 @@ import 'server-only'; import { db } from './db'; -import { EnrollmentStatus } from './enums'; +import { EnrollmentStatus, PaymentStatus } from './enums'; import { generateClaimToken, PLACEHOLDER_PASSWORD_PREFIX } from './claim-token'; import { randomUUID } from 'node:crypto'; @@ -65,10 +65,21 @@ export async function findOrCreateUserByEmail( return { id: placeholder.id, isNew: true, rawClaimToken: claim.raw }; } +export interface ManualEnrollmentPaymentInput { + /** One of the PaymentMethod values (e.g. GCASH, BANK_TRANSFER, OTHER). */ + method: string; + /** Amount actually received, in centavos. */ + amountPhp: number; + /** Free-text reference for bookkeeping, e.g. a GCash ref # or receipt note. */ + reference?: string | null; +} + export interface ManualEnrollmentInput { email: string; name?: string | null; pricingTierId: string; + /** Omit for comp/free grants — no Payment row is created. */ + payment?: ManualEnrollmentPaymentInput; } export interface ManualEnrollmentResult { @@ -79,6 +90,7 @@ export interface ManualEnrollmentResult { tierName: string; enrolledCourseIds: string[]; alreadyEnrolledCourseIds: string[]; + paymentRecorded: boolean; } /** @@ -93,6 +105,7 @@ export async function grantManualEnrollment({ email, name, pricingTierId, + payment, }: ManualEnrollmentInput): Promise { const tier = await db.pricingTier.findUnique({ where: { id: pricingTierId }, @@ -137,6 +150,23 @@ export async function grantManualEnrollment({ }); } + if (payment) { + // Not tied to a single Enrollment — a tier can bundle several courses, + // so this records one payment for the whole grant, not per course. + await tx.payment.create({ + data: { + userId: user.id, + pricingTierId: tier.id, + amountPhp: payment.amountPhp, + netAmountPhp: payment.amountPhp, + method: payment.method, + status: PaymentStatus.COMPLETED, + paidAt: new Date(), + metadata: payment.reference?.trim() || null, + }, + }); + } + return { userId: user.id, isNewUser: user.isNew, @@ -144,6 +174,7 @@ export async function grantManualEnrollment({ tierName: tier.name, enrolledCourseIds: toCreate, alreadyEnrolledCourseIds: [...alreadyEnrolled], + paymentRecorded: !!payment, }; }); } From 3eca663f3159146d4bdfb33b6b4d91e97e02b6d5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:48:22 +0000 Subject: [PATCH 3/4] Make manual-enroll audit write atomic with the payment/enrollment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses a CodeRabbit review finding on PR #93: the AuditLog write happened as a separate db call after the enrollment/payment transaction committed. If that write failed, the action returned an error even though the Payment row already existed — and since nothing gated payment creation on idempotency, retrying the same submission would create a duplicate COMPLETED payment. grantManualEnrollment() now accepts an `audit` context and writes the AuditLog row with the same transaction client as the enrollment and payment writes, so the whole grant commits or rolls back together. Also fixed an em dash CodeRabbit flagged in a comment (repo style guidelines prohibit them). Not done: a durable idempotency key / outbox pattern for the payment write itself, which CodeRabbit's suggestion also called for. This is a low-traffic, solo-admin internal tool where an accidental duplicate payment is easy to spot and soft-delete in the admin UI; the atomicity fix above removes the concrete failure mode described (committed payment reported as failed, prompting a retry). Skipping the added infra as disproportionate to this feature's scope. --- src/app/actions/admin-enroll.ts | 23 ++++++----------- src/lib/enrollment.ts | 44 +++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/src/app/actions/admin-enroll.ts b/src/app/actions/admin-enroll.ts index ce4f408..3452f4c 100644 --- a/src/app/actions/admin-enroll.ts +++ b/src/app/actions/admin-enroll.ts @@ -11,8 +11,8 @@ import { z } from 'zod'; import { revalidatePath } from 'next/cache'; +import { headers } from 'next/headers'; import { requireAdmin } from '@/lib/auth'; -import { auditLog } from '@/lib/admin-audit'; import { grantManualEnrollment } from '@/lib/enrollment'; import { PaymentMethodValues } from '@/lib/enums'; import type { ActionResult } from '@/lib/validation'; @@ -45,7 +45,7 @@ export interface ManualEnrollActionData { export async function manualEnrollAction( input: z.infer, ): Promise> { - await requireAdmin(); + const actor = await requireAdmin(); const parsed = manualEnrollSchema.safeParse(input); if (!parsed.success) { @@ -56,26 +56,19 @@ export async function manualEnrollAction( } try { + const heads = await headers(); const result = await grantManualEnrollment({ email: parsed.data.email, name: parsed.data.name || null, pricingTierId: parsed.data.pricingTierId, payment: parsed.data.payment, + audit: { + actorId: actor.id, + ipAddress: heads.get('x-forwarded-for') ?? heads.get('x-real-ip'), + userAgent: heads.get('user-agent'), + }, }); - await auditLog({ - action: 'MANUAL_ENROLL', - entityType: 'User', - entityId: result.userId, - metadata: parsed.data.payment - ? { - tier: result.tierName, - paymentMethod: parsed.data.payment.method, - amountPhp: parsed.data.payment.amountPhp, - reference: parsed.data.payment.reference || undefined, - } - : undefined, - }); revalidatePath('/admin/users'); revalidatePath(`/admin/users/${result.userId}`); diff --git a/src/lib/enrollment.ts b/src/lib/enrollment.ts index b7ff4ba..ee7b994 100644 --- a/src/lib/enrollment.ts +++ b/src/lib/enrollment.ts @@ -74,12 +74,25 @@ export interface ManualEnrollmentPaymentInput { reference?: string | null; } +export interface ManualEnrollmentAuditInput { + actorId: string; + ipAddress?: string | null; + userAgent?: string | null; +} + export interface ManualEnrollmentInput { email: string; name?: string | null; pricingTierId: string; - /** Omit for comp/free grants — no Payment row is created. */ + /** Omit for comp/free grants. No Payment row is created. */ payment?: ManualEnrollmentPaymentInput; + /** + * When present, an AuditLog row is written in the same transaction as the + * enrollment/payment writes — so a commit can never be reported back as a + * failure (and retried into a duplicate payment) just because the audit + * write failed separately afterward. + */ + audit?: ManualEnrollmentAuditInput; } export interface ManualEnrollmentResult { @@ -106,6 +119,7 @@ export async function grantManualEnrollment({ name, pricingTierId, payment, + audit, }: ManualEnrollmentInput): Promise { const tier = await db.pricingTier.findUnique({ where: { id: pricingTierId }, @@ -151,7 +165,7 @@ export async function grantManualEnrollment({ } if (payment) { - // Not tied to a single Enrollment — a tier can bundle several courses, + // Not tied to a single Enrollment. A tier can bundle several courses, // so this records one payment for the whole grant, not per course. await tx.payment.create({ data: { @@ -167,6 +181,32 @@ export async function grantManualEnrollment({ }); } + if (audit) { + // Written via `tx`, not the separate auditLog() helper, so this commits + // atomically with the enrollment/payment writes above: a failure here + // rolls back the whole grant instead of leaving a committed payment + // that gets reported to the admin as a failure (and risks a duplicate + // payment on retry). + await tx.auditLog.create({ + data: { + actorId: audit.actorId, + action: 'MANUAL_ENROLL', + entityType: 'User', + entityId: user.id, + metadata: payment + ? JSON.stringify({ + tier: tier.name, + paymentMethod: payment.method, + amountPhp: payment.amountPhp, + reference: payment.reference?.trim() || undefined, + }) + : null, + ipAddress: audit.ipAddress ?? null, + userAgent: audit.userAgent ?? null, + }, + }); + } + return { userId: user.id, isNewUser: user.isNew, From 7cc4120c86c3b6080f38b9296512ed24e82847f7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 03:51:28 +0000 Subject: [PATCH 4/4] Add test coverage for manual-enroll payment/audit branches CI's Quality Gates job failed on 3eca663: global branch coverage dropped to 68.39%, below the 70% threshold, because the new if (payment) / if (audit) branches in grantManualEnrollment() had no test coverage. Adds three cases to enrollment.test.ts: no payment/audit provided, a payment + audit recorded together (asserting the Payment and AuditLog write shapes, including the JSON metadata), and audit provided without a payment (null metadata). enrollment.ts branch coverage goes from 57.14% to 92.85%, bringing the global figure to 71.55%. --- src/lib/__tests__/enrollment.test.ts | 93 ++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/src/lib/__tests__/enrollment.test.ts b/src/lib/__tests__/enrollment.test.ts index 4498df7..686957e 100644 --- a/src/lib/__tests__/enrollment.test.ts +++ b/src/lib/__tests__/enrollment.test.ts @@ -6,6 +6,8 @@ const mockDb = vi.hoisted(() => { user: { findUnique: fn(), create: fn() }, pricingTier: { findUnique: fn() }, enrollment: { findMany: fn(), createMany: fn() }, + payment: { create: fn() }, + auditLog: { create: fn() }, $transaction: vi.fn(), }; }); @@ -14,6 +16,7 @@ vi.mock('@/lib/db', () => ({ db: mockDb })); const mockEnums = vi.hoisted(() => ({ EnrollmentStatus: { ACTIVE: 'ACTIVE' }, + PaymentStatus: { COMPLETED: 'COMPLETED' }, })); vi.mock('@/lib/enums', () => mockEnums); @@ -175,4 +178,94 @@ describe('grantManualEnrollment', () => { expect(result.enrolledCourseIds).toEqual([]); expect(mockDb.enrollment.createMany).not.toHaveBeenCalled(); }); + + it('does not create a Payment or AuditLog row when payment/audit are omitted', async () => { + mockDb.pricingTier.findUnique.mockResolvedValue(tier); + mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' }); + mockDb.enrollment.findMany.mockResolvedValue([]); + mockDb.enrollment.createMany.mockResolvedValue({ count: 2 }); + + const result = await grantManualEnrollment({ + email: 'student@email.com', + pricingTierId: 'tier-1', + }); + + expect(result.paymentRecorded).toBe(false); + expect(mockDb.payment.create).not.toHaveBeenCalled(); + expect(mockDb.auditLog.create).not.toHaveBeenCalled(); + }); + + it('records a completed Payment and an AuditLog row in the same transaction', async () => { + mockDb.pricingTier.findUnique.mockResolvedValue(tier); + mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' }); + mockDb.enrollment.findMany.mockResolvedValue([]); + mockDb.enrollment.createMany.mockResolvedValue({ count: 2 }); + mockDb.payment.create.mockResolvedValue({ id: 'payment-1' }); + mockDb.auditLog.create.mockResolvedValue({ id: 'audit-1' }); + + const result = await grantManualEnrollment({ + email: 'student@email.com', + pricingTierId: 'tier-1', + payment: { + method: 'GCASH', + amountPhp: 299900, + reference: ' ref-123 ', + }, + audit: { + actorId: 'admin-1', + ipAddress: '127.0.0.1', + userAgent: 'vitest', + }, + }); + + expect(result.paymentRecorded).toBe(true); + + const paymentArg = mockDb.payment.create.mock.calls[0]![0]!; + expect(paymentArg.data).toMatchObject({ + userId: 'user-1', + pricingTierId: 'tier-1', + amountPhp: 299900, + netAmountPhp: 299900, + method: 'GCASH', + status: 'COMPLETED', + metadata: 'ref-123', + }); + expect(paymentArg.data.paidAt).toBeInstanceOf(Date); + + const auditArg = mockDb.auditLog.create.mock.calls[0]![0]!; + expect(auditArg.data).toMatchObject({ + actorId: 'admin-1', + action: 'MANUAL_ENROLL', + entityType: 'User', + entityId: 'user-1', + ipAddress: '127.0.0.1', + userAgent: 'vitest', + }); + expect(JSON.parse(auditArg.data.metadata)).toEqual({ + tier: 'PPC Foundations', + paymentMethod: 'GCASH', + amountPhp: 299900, + reference: 'ref-123', + }); + }); + + it('writes an AuditLog row with null metadata when no payment is recorded', async () => { + mockDb.pricingTier.findUnique.mockResolvedValue(tier); + mockDb.user.findUnique.mockResolvedValue({ id: 'user-1' }); + mockDb.enrollment.findMany.mockResolvedValue([]); + mockDb.enrollment.createMany.mockResolvedValue({ count: 2 }); + mockDb.auditLog.create.mockResolvedValue({ id: 'audit-1' }); + + await grantManualEnrollment({ + email: 'student@email.com', + pricingTierId: 'tier-1', + audit: { actorId: 'admin-1' }, + }); + + expect(mockDb.payment.create).not.toHaveBeenCalled(); + const auditArg = mockDb.auditLog.create.mock.calls[0]![0]!; + expect(auditArg.data.metadata).toBeNull(); + expect(auditArg.data.ipAddress).toBeNull(); + expect(auditArg.data.userAgent).toBeNull(); + }); });