Skip to content

Commit 81528aa

Browse files
authored
fix: harden transaction status updates and expand email/e2e coverage (#228)
1 parent d883423 commit 81528aa

9 files changed

Lines changed: 292 additions & 14 deletions

app/models/enum.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,10 @@ export const TRANSACTION_STATUS = {
2727
SUBMITTED: 'SUBMITTED',
2828
VERIFIED: 'VERIFIED',
2929
REJECTED: 'REJECTED',
30-
}
30+
} as const
3131

32-
export type TransactionStatus = 'SUBMITTED' | 'VERIFIED' | 'REJECTED'
32+
export type TransactionStatus =
33+
typeof TRANSACTION_STATUS[keyof typeof TRANSACTION_STATUS]
3334

3435
export const TRANSACTION_METHOD = {
3536
BANK_TRANSFER: 'BANK_TRANSFER',

app/models/transaction.ts

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -102,10 +102,26 @@ export async function getTransactionById(
102102
export async function updateTransactionStatus(
103103
id: string,
104104
notes: string,
105-
status: TransactionStatus
105+
status: TransactionStatus,
106+
allowedCurrentStatuses?: TransactionStatus[]
106107
) {
107-
return await db.transaction.update({
108-
where: { id },
109-
data: { notes, status },
110-
})
108+
try {
109+
return await db.transaction.update({
110+
where: allowedCurrentStatuses
111+
? { id, status: { in: allowedCurrentStatuses } }
112+
: { id },
113+
data: { notes, status },
114+
})
115+
} catch (e: unknown) {
116+
// Prisma P2025: record not found (concurrent status change)
117+
if (
118+
e instanceof Error &&
119+
'code' in e &&
120+
(e as { code: string }).code === 'P2025'
121+
) {
122+
return null
123+
}
124+
125+
throw e
126+
}
111127
}
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { action } from '../dashboard.transactions.$transactionId.$action'
3+
import { TRANSACTION_STATUS } from '~/models/enum'
4+
import { requireUser } from '~/services/auth.server'
5+
import { getFirstCourse } from '~/models/course'
6+
import { requireCourseAuthor } from '~/utils/permissions'
7+
import {
8+
getTransactionById,
9+
updateTransactionStatus,
10+
} from '~/models/transaction'
11+
import {
12+
activateSubscription,
13+
deactivateSubscription,
14+
} from '~/models/subscription'
15+
16+
vi.mock('~/services/auth.server', () => ({
17+
requireUser: vi.fn(),
18+
}))
19+
20+
vi.mock('~/models/course', () => ({
21+
getFirstCourse: vi.fn(),
22+
}))
23+
24+
vi.mock('~/utils/permissions', () => ({
25+
requireCourseAuthor: vi.fn(),
26+
}))
27+
28+
vi.mock('~/models/transaction', () => ({
29+
getTransactionById: vi.fn(),
30+
updateTransactionStatus: vi.fn(),
31+
}))
32+
33+
vi.mock('~/models/subscription', () => ({
34+
activateSubscription: vi.fn(),
35+
deactivateSubscription: vi.fn(),
36+
}))
37+
38+
function buildRequest(status: string, notes = 'Catatan verifikasi') {
39+
const formData = new URLSearchParams({ status, notes })
40+
41+
return new Request(
42+
'http://localhost:3000/dashboard/transactions/tx-1/verify',
43+
{
44+
method: 'POST',
45+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
46+
body: formData,
47+
}
48+
)
49+
}
50+
51+
async function runAction(status: string) {
52+
return action({
53+
request: buildRequest(status),
54+
params: { transactionId: 'tx-1' },
55+
context: {},
56+
} as never)
57+
}
58+
59+
describe('dashboard.transactions.$transactionId.$action action', () => {
60+
beforeEach(() => {
61+
vi.resetAllMocks()
62+
63+
vi.mocked(requireUser).mockResolvedValue({ id: 'author-1' } as never)
64+
vi.mocked(getFirstCourse).mockResolvedValue({ id: 'course-1' } as never)
65+
vi.mocked(requireCourseAuthor).mockReturnValue(true)
66+
vi.mocked(getTransactionById).mockResolvedValue({
67+
id: 'tx-1',
68+
status: TRANSACTION_STATUS.SUBMITTED,
69+
userId: 'user-1',
70+
} as never)
71+
vi.mocked(updateTransactionStatus).mockResolvedValue({
72+
id: 'tx-1',
73+
status: TRANSACTION_STATUS.VERIFIED,
74+
userId: 'user-1',
75+
} as never)
76+
vi.mocked(activateSubscription).mockResolvedValue({ id: 'sub-1' } as never)
77+
vi.mocked(deactivateSubscription).mockResolvedValue({
78+
id: 'sub-1',
79+
} as never)
80+
})
81+
82+
it('rejects tampered status payloads that are outside allowlist', async () => {
83+
try {
84+
await runAction(TRANSACTION_STATUS.SUBMITTED)
85+
throw new Error('Expected action to throw Response')
86+
} catch (error) {
87+
expect(error).toBeInstanceOf(Response)
88+
const response = error as Response
89+
expect(response.status).toBe(400)
90+
await expect(response.json()).resolves.toBe(
91+
'Status transaksi tidak valid.'
92+
)
93+
}
94+
95+
expect(updateTransactionStatus).not.toHaveBeenCalled()
96+
})
97+
98+
it('rejects forbidden transition from VERIFIED to REJECTED', async () => {
99+
vi.mocked(getTransactionById).mockResolvedValue({
100+
id: 'tx-1',
101+
status: TRANSACTION_STATUS.VERIFIED,
102+
userId: 'user-1',
103+
} as never)
104+
105+
try {
106+
await runAction(TRANSACTION_STATUS.REJECTED)
107+
throw new Error('Expected action to throw Response')
108+
} catch (error) {
109+
expect(error).toBeInstanceOf(Response)
110+
const response = error as Response
111+
expect(response.status).toBe(400)
112+
await expect(response.json()).resolves.toBe(
113+
'Transaksi yang sudah diverifikasi tidak dapat diubah menjadi ditolak.'
114+
)
115+
}
116+
117+
expect(updateTransactionStatus).not.toHaveBeenCalled()
118+
})
119+
120+
it('accepts VERIFIED status and updates transaction', async () => {
121+
const response = await runAction(TRANSACTION_STATUS.VERIFIED)
122+
123+
expect(response).toBeInstanceOf(Response)
124+
if (!(response instanceof Response)) throw new Error('Expected Response')
125+
expect(response.status).toBe(302)
126+
expect(response.headers.get('Location')).toBe(
127+
'/dashboard/transactions/tx-1'
128+
)
129+
expect(updateTransactionStatus).toHaveBeenCalledWith(
130+
'tx-1',
131+
'Catatan verifikasi',
132+
TRANSACTION_STATUS.VERIFIED,
133+
[TRANSACTION_STATUS.SUBMITTED, TRANSACTION_STATUS.REJECTED]
134+
)
135+
})
136+
})

app/routes/dashboard.transactions.$transactionId.$action.tsx

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
TransactionWithUser,
1616
updateTransactionStatus,
1717
} from '~/models/transaction'
18+
import { canUpdateTransactionStatus } from '~/utils/transaction-status'
1819
import { printLocaleDateTimeString, printRupiah } from '~/utils/format'
1920
import { TransactionStatus, TRANSACTION_STATUS } from '~/models/enum'
2021
import { requireUser } from '~/services/auth.server'
@@ -75,13 +76,42 @@ export const action: ActionFunction = async ({ request, params }) => {
7576
}
7677
}
7778

79+
if (
80+
status !== TRANSACTION_STATUS.VERIFIED &&
81+
status !== TRANSACTION_STATUS.REJECTED
82+
) {
83+
throw json('Status transaksi tidak valid.', { status: 400 })
84+
}
85+
86+
const existingTransaction = await getTransactionById(transactionId)
87+
if (!existingTransaction) {
88+
return redirect('/dashboard/transactions')
89+
}
90+
91+
if (
92+
!canUpdateTransactionStatus(
93+
existingTransaction.status as TransactionStatus,
94+
status as TransactionStatus
95+
)
96+
) {
97+
throw json(
98+
'Transaksi yang sudah diverifikasi tidak dapat diubah menjadi ditolak.',
99+
{ status: 400 }
100+
)
101+
}
102+
78103
const transaction = await updateTransactionStatus(
79104
transactionId,
80105
notes,
81-
status as TransactionStatus
106+
status as TransactionStatus,
107+
status === TRANSACTION_STATUS.REJECTED
108+
? [TRANSACTION_STATUS.SUBMITTED]
109+
: [TRANSACTION_STATUS.SUBMITTED, TRANSACTION_STATUS.REJECTED]
82110
)
83111
if (!transaction) {
84-
throw json({ transaction, notes, status }, { status: 500 })
112+
throw json('Transaksi gagal diperbarui karena status sudah berubah.', {
113+
status: 409,
114+
})
85115
}
86116

87117
if (transaction.status === TRANSACTION_STATUS.VERIFIED) {

app/routes/dashboard.transactions.$transactionId.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ export default function TransactionDetailsPage() {
4949
<SingleColumnLayout>
5050
<div className="min-h-full">
5151
<TransactionDetails transaction={transaction} user={transaction.user}>
52-
{/* TODO: Disable rejecting a verified transaction */}
5352
<TertiaryButtonLink
5453
to={`reject?${searchParams.toString()}`}
5554
replace
56-
disabled={transaction.status === TRANSACTION_STATUS.REJECTED}
55+
disabled={
56+
transaction.status === TRANSACTION_STATUS.REJECTED ||
57+
transaction.status === TRANSACTION_STATUS.VERIFIED
58+
}
5759
>
5860
Tolak Transaksi
5961
</TertiaryButtonLink>
Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,19 @@
1+
import { vi, describe, it, expect, beforeEach } from 'vitest'
12
import { User } from '@prisma/client'
23
import { sendEmail } from '../email.server'
4+
import * as emailProvider from '~/services/email-provider.server'
35
import { userBuilder } from '~/models/__mocks__/user'
46

57
describe('sendEmail', () => {
68
const user = userBuilder() as User
79

10+
beforeEach(() => {
11+
vi.restoreAllMocks()
12+
})
13+
814
it('call emailProvider.sendEmail method', async () => {
15+
const spy = vi.spyOn(emailProvider, 'sendEmail')
16+
917
await sendEmail({
1018
emailAddress: user.email,
1119
magicLink: 'http://localhost:3000/magic',
@@ -14,6 +22,14 @@ describe('sendEmail', () => {
1422
form: new FormData(),
1523
})
1624

17-
// TODO: assert emailProvider.sendEmail was called
25+
expect(spy).toHaveBeenCalledOnce()
26+
expect(spy).toHaveBeenCalledWith(
27+
expect.objectContaining({
28+
to: user.email,
29+
from: expect.any(String),
30+
subject: expect.any(String),
31+
html: expect.stringContaining('localhost:3000/magic'),
32+
})
33+
)
1834
})
1935
})
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { canUpdateTransactionStatus } from '../transaction-status'
2+
import { TRANSACTION_STATUS } from '~/models/enum'
3+
4+
describe('canUpdateTransactionStatus', () => {
5+
it('allows submitted to verified', () => {
6+
expect(
7+
canUpdateTransactionStatus(
8+
TRANSACTION_STATUS.SUBMITTED,
9+
TRANSACTION_STATUS.VERIFIED
10+
)
11+
).toBe(true)
12+
})
13+
14+
it('allows submitted to rejected', () => {
15+
expect(
16+
canUpdateTransactionStatus(
17+
TRANSACTION_STATUS.SUBMITTED,
18+
TRANSACTION_STATUS.REJECTED
19+
)
20+
).toBe(true)
21+
})
22+
23+
it('prevents verified from being rejected', () => {
24+
expect(
25+
canUpdateTransactionStatus(
26+
TRANSACTION_STATUS.VERIFIED,
27+
TRANSACTION_STATUS.REJECTED
28+
)
29+
).toBe(false)
30+
})
31+
32+
it('allows verified to remain verified', () => {
33+
expect(
34+
canUpdateTransactionStatus(
35+
TRANSACTION_STATUS.VERIFIED,
36+
TRANSACTION_STATUS.VERIFIED
37+
)
38+
).toBe(true)
39+
})
40+
41+
it('allows rejected to be re-verified', () => {
42+
expect(
43+
canUpdateTransactionStatus(
44+
TRANSACTION_STATUS.REJECTED,
45+
TRANSACTION_STATUS.VERIFIED
46+
)
47+
).toBe(true)
48+
})
49+
50+
it('allows rejected to remain rejected', () => {
51+
expect(
52+
canUpdateTransactionStatus(
53+
TRANSACTION_STATUS.REJECTED,
54+
TRANSACTION_STATUS.REJECTED
55+
)
56+
).toBe(true)
57+
})
58+
})

app/utils/transaction-status.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { TransactionStatus, TRANSACTION_STATUS } from '~/models/enum'
2+
3+
export function canUpdateTransactionStatus(
4+
currentStatus: TransactionStatus,
5+
destinationStatus: TransactionStatus
6+
) {
7+
if (
8+
currentStatus === TRANSACTION_STATUS.VERIFIED &&
9+
destinationStatus === TRANSACTION_STATUS.REJECTED
10+
) {
11+
return false
12+
}
13+
14+
return true
15+
}

e2e/profile.spec.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ test('Validate phone number when updating data', async ({
3030
name: /nomor whatsapp/i,
3131
})
3232

33-
// Fill phoneNumber with invalid value
33+
// Click first to ensure onFocus fires reliably on WebKit (iPhone 11),
34+
// then fill with invalid value and blur to trigger client-side validation.
35+
await phoneNumber.click()
3436
await phoneNumber.fill('6512345678')
3537
await phoneNumber.blur()
3638

@@ -64,7 +66,9 @@ test('Validate name when updating data', async ({ page, noscript, screen }) => {
6466
name: /nama lengkap/i,
6567
})
6668

67-
// Clear name and trigger validation
69+
// Click first to ensure onFocus fires reliably on WebKit (iPhone 11),
70+
// then clear and blur to trigger client-side validation.
71+
await name.click()
6872
await name.fill('')
6973
await name.blur()
7074

0 commit comments

Comments
 (0)