forked from Nova-reward/Nova-Rewards
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathredemptionRepository.test.js
More file actions
146 lines (123 loc) · 6.25 KB
/
Copy pathredemptionRepository.test.js
File metadata and controls
146 lines (123 loc) · 6.25 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// Unit tests for redemptionRepository — verifies the atomic DB transaction logic
jest.mock('../db/index', () => ({
query: jest.fn(),
pool: { connect: jest.fn(), query: jest.fn() },
}));
const { pool } = require('../db/index');
const { redeemReward, getRedemptionById, getUserRedemptions } = require('../db/redemptionRepository');
// Build a mock pg client with a controllable query implementation
function buildClient(queryImpl) {
const client = {
query: jest.fn(queryImpl),
release: jest.fn(),
};
pool.connect.mockResolvedValue(client);
return client;
}
beforeEach(() => jest.clearAllMocks());
describe('redeemReward', () => {
const BASE_PARAMS = { userId: 1, rewardId: 5, idempotencyKey: 'key-abc' };
test('returns existing redemption on idempotent replay', async () => {
const existing = { id: 10, user_id: 1, reward_id: 5, points_spent: 100, idempotency_key: 'key-abc' };
buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [existing] };
return { rows: [] };
});
const result = await redeemReward(BASE_PARAMS);
expect(result.idempotent).toBe(true);
expect(result.redemption).toEqual(existing);
});
test('completes a fresh redemption atomically', async () => {
const reward = { id: 5, name: 'Coffee', cost: '100', stock: 3, is_active: true, is_deleted: false };
const pointTx = { id: 42, user_id: 1, type: 'redeemed', amount: 100, balance_before: 500, balance_after: 400 };
const redemption = { id: 10, user_id: 1, reward_id: 5, points_spent: 100 };
buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'COMMIT') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [] }; // no existing
if (sql.includes('FROM rewards')) return { rows: [reward] };
if (sql.includes('INSERT INTO user_balance')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM user_balance')) return { rows: [{ balance: 500 }] };
if (sql.includes('UPDATE rewards')) return { rows: [], rowCount: 1 };
if (sql.includes('INSERT INTO point_transactions')) return { rows: [pointTx] };
if (sql.includes('INSERT INTO redemptions')) return { rows: [redemption] };
return { rows: [] };
});
const result = await redeemReward(BASE_PARAMS);
expect(result.idempotent).toBe(false);
expect(result.redemption).toEqual(redemption);
expect(result.pointTx).toEqual(pointTx);
});
test('rolls back and throws 404 when reward not found', async () => {
const client = buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'ROLLBACK') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [] };
if (sql.includes('FROM rewards')) return { rows: [] }; // not found
return { rows: [] };
});
await expect(redeemReward(BASE_PARAMS)).rejects.toMatchObject({ status: 404, code: 'not_found' });
expect(client.release).toHaveBeenCalled();
});
test('rolls back and throws 409 when out of stock', async () => {
const reward = { id: 5, name: 'Coffee', cost: '100', stock: 0, is_active: true, is_deleted: false };
buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'ROLLBACK') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [] };
if (sql.includes('FROM rewards')) return { rows: [reward] };
return { rows: [] };
});
await expect(redeemReward(BASE_PARAMS)).rejects.toMatchObject({ status: 409, code: 'out_of_stock' });
});
test('rolls back and throws 409 when insufficient points', async () => {
const reward = { id: 5, name: 'Coffee', cost: '500', stock: 10, is_active: true, is_deleted: false };
buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'ROLLBACK') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [] };
if (sql.includes('FROM rewards')) return { rows: [reward] };
if (sql.includes('INSERT INTO user_balance')) return { rows: [], rowCount: 0 };
if (sql.includes('FROM user_balance')) return { rows: [{ balance: 100 }] }; // only 100 pts
return { rows: [] };
});
await expect(redeemReward(BASE_PARAMS)).rejects.toMatchObject({ status: 409, code: 'insufficient_points' });
});
test('rolls back and throws 409 when reward is inactive', async () => {
const reward = { id: 5, name: 'Coffee', cost: '100', stock: 5, is_active: false, is_deleted: false };
buildClient(async (sql) => {
if (sql === 'BEGIN' || sql === 'ROLLBACK') return { rows: [] };
if (sql.includes('FROM redemptions')) return { rows: [] };
if (sql.includes('FROM rewards')) return { rows: [reward] };
return { rows: [] };
});
await expect(redeemReward(BASE_PARAMS)).rejects.toMatchObject({ status: 409, code: 'reward_inactive' });
});
test('releases client even when an unexpected error is thrown', async () => {
const client = buildClient(async (sql) => {
if (sql === 'BEGIN') return { rows: [] };
if (sql === 'ROLLBACK') return { rows: [] };
throw new Error('unexpected DB error');
});
await expect(redeemReward(BASE_PARAMS)).rejects.toThrow('unexpected DB error');
expect(client.release).toHaveBeenCalled();
});
});
describe('getRedemptionById', () => {
test('returns redemption when found', async () => {
const row = { id: 10, user_id: 1, reward_name: 'Coffee' };
pool.query = jest.fn().mockResolvedValue({ rows: [row] });
expect(await getRedemptionById(10, 1)).toEqual(row);
});
test('returns null when not found', async () => {
pool.query = jest.fn().mockResolvedValue({ rows: [] });
expect(await getRedemptionById(999, 1)).toBeNull();
});
});
describe('getUserRedemptions', () => {
test('returns paginated redemptions', async () => {
pool.query = jest.fn()
.mockResolvedValueOnce({ rows: [{ total: '3' }] })
.mockResolvedValueOnce({ rows: [{ id: 1 }, { id: 2 }, { id: 3 }] });
const result = await getUserRedemptions(1, { page: 1, limit: 20 });
expect(result.total).toBe(3);
expect(result.data).toHaveLength(3);
});
});