Skip to content

Commit bf057b4

Browse files
dwhoganmauberti-bc
andauthored
SIMSBIOHUB-862: Checking out Shopping Cart (#337)
* feat(cart): add checkout repository and service methods * feat(cart): add POST /api/cart/{cartId}/checkout endpoint * feat(cart): frontend UI, API, context * refactor(checkout): API fixes * refactor(checkout): Refactored publisher to use db * create team during cart checkout * navigate to download after cart checkout --------- Co-authored-by: Macgregor Aubertin-Young <macgregor.aubertin-young@gov.bc.ca> Co-authored-by: Macgregor Aubertin-Young <108430771+mauberti-bc@users.noreply.github.com>
1 parent 5a52564 commit bf057b4

29 files changed

+1157
-73
lines changed
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Integration test for cart checkout — verifies the full checkout flow
2+
// (get feature IDs, create download, link features, update cart status)
3+
// works correctly against the real database.
4+
//
5+
// Uses a transaction that is ROLLED BACK after each test, so no data is persisted.
6+
//
7+
// Run: make test-db
8+
// Requires: make web (database must be running with seed data)
9+
10+
import { expect } from 'chai';
11+
import SQL from 'sql-template-strings';
12+
import { defaultPoolConfig, getAPIUserDBConnection, IDBConnection, initDBPool } from '../../database/db';
13+
import { HTTP400 } from '../../errors/http-error';
14+
import { CartService } from '../../services/cart-service';
15+
import { DownloadService } from '../../services/download/download-service';
16+
17+
describe('Cart checkout (integration)', function () {
18+
this.timeout(15000);
19+
20+
let connection: IDBConnection;
21+
let cartService: CartService;
22+
let downloadService: DownloadService;
23+
24+
before(() => {
25+
initDBPool(defaultPoolConfig);
26+
});
27+
28+
beforeEach(async () => {
29+
connection = getAPIUserDBConnection();
30+
await connection.open();
31+
cartService = new CartService(connection);
32+
downloadService = new DownloadService(connection);
33+
});
34+
35+
afterEach(async () => {
36+
await connection.rollback();
37+
connection.release();
38+
});
39+
40+
/**
41+
* Helper: insert a minimal submission and return its ID.
42+
*/
43+
async function createTestSubmission(): Promise<number> {
44+
const systemUserId = connection.systemUserId();
45+
46+
const result = await connection.sql(SQL`
47+
INSERT INTO submission (uuid, system_user_id, source_system, name, description, comment, create_user)
48+
VALUES (gen_random_uuid(), ${systemUserId}, 'SIMS', 'Cart Checkout Test', 'Test', 'Test', ${systemUserId})
49+
RETURNING submission_id;
50+
`);
51+
52+
return result.rows[0].submission_id;
53+
}
54+
55+
/**
56+
* Helper: insert a submission_feature and return its ID.
57+
*/
58+
async function createTestFeature(submissionId: number, data: Record<string, unknown>): Promise<number> {
59+
const systemUserId = connection.systemUserId();
60+
const dataJson = JSON.stringify(data);
61+
62+
const result = await connection.sql(SQL`
63+
INSERT INTO submission_feature (submission_id, feature_type_id, data, data_byte_size, create_user)
64+
VALUES (
65+
${submissionId},
66+
(SELECT feature_type_id FROM feature_type WHERE name = 'dataset' LIMIT 1),
67+
${dataJson}::jsonb,
68+
octet_length(${dataJson}::jsonb::text),
69+
${systemUserId}
70+
)
71+
RETURNING submission_feature_id;
72+
`);
73+
74+
return result.rows[0].submission_feature_id;
75+
}
76+
77+
/**
78+
* Helper: create a cart, add features, and return the cart ID.
79+
*/
80+
async function createCartWithFeatures(featureIds: number[]): Promise<string> {
81+
const systemUserId = connection.systemUserId();
82+
const response = await cartService.createCart(systemUserId, featureIds);
83+
84+
return response.cart.cart_id;
85+
}
86+
87+
it('should create download, link features, and mark cart as checked out', async () => {
88+
// Setup: create submission with two features and a cart
89+
const submissionId = await createTestSubmission();
90+
const featureId1 = await createTestFeature(submissionId, { name: 'Dataset A' });
91+
const featureId2 = await createTestFeature(submissionId, { name: 'Dataset B' });
92+
const cartId = await createCartWithFeatures([featureId1, featureId2]);
93+
94+
const systemUserId = connection.systemUserId();
95+
96+
// Act: checkout
97+
const result = await cartService.checkoutCart(cartId, systemUserId);
98+
99+
// Verify: download record exists with pending status
100+
const download = await downloadService.findDownloadById(result.download_id);
101+
expect(download).to.not.be.null;
102+
expect(download!.download_status).to.equal('pending');
103+
expect(download!.system_user_id).to.equal(systemUserId);
104+
105+
// Verify: both features linked in download_feature
106+
const features = await connection.sql(SQL`
107+
SELECT submission_feature_id FROM download_feature
108+
WHERE download_id = ${result.download_id}
109+
ORDER BY submission_feature_id;
110+
`);
111+
expect(features.rows).to.have.length(2);
112+
expect(features.rows.map((r: { submission_feature_id: number }) => r.submission_feature_id)).to.deep.equal([
113+
featureId1,
114+
featureId2
115+
]);
116+
117+
// Verify: cart status is checked_out with checkout metadata
118+
const cart = await connection.sql(SQL`
119+
SELECT cart_status, checkout_date, checkout_user FROM cart WHERE cart_id = ${cartId};
120+
`);
121+
expect(cart.rows[0].cart_status).to.equal('checked_out');
122+
expect(cart.rows[0].checkout_date).to.not.be.null;
123+
expect(cart.rows[0].checkout_user).to.equal(systemUserId);
124+
});
125+
126+
it('should throw HTTP400 when checking out an empty cart', async () => {
127+
const cartId = await createCartWithFeatures([]);
128+
const systemUserId = connection.systemUserId();
129+
130+
try {
131+
await cartService.checkoutCart(cartId, systemUserId);
132+
expect.fail('Expected HTTP400');
133+
} catch (error) {
134+
expect(error).to.be.instanceOf(HTTP400);
135+
expect((error as HTTP400).message).to.equal('Cannot checkout an empty cart');
136+
}
137+
});
138+
139+
it('should fail on double checkout (cart is no longer active)', async () => {
140+
const submissionId = await createTestSubmission();
141+
const featureId = await createTestFeature(submissionId, { name: 'Dataset' });
142+
const cartId = await createCartWithFeatures([featureId]);
143+
const systemUserId = connection.systemUserId();
144+
145+
// First checkout succeeds
146+
await cartService.checkoutCart(cartId, systemUserId);
147+
148+
// Second checkout fails — cart status is 'checked_out', so getCartSubmissionFeatureIds
149+
// returns empty (it filters on cart_status = 'active'), triggering the empty-cart guard
150+
try {
151+
await cartService.checkoutCart(cartId, systemUserId);
152+
expect.fail('Expected error on double checkout');
153+
} catch (error) {
154+
expect(error).to.be.instanceOf(HTTP400);
155+
expect((error as HTTP400).message).to.equal('Cannot checkout an empty cart');
156+
}
157+
});
158+
});

api/src/__integration__/db/download-service.integration.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,15 @@ describe('DownloadPipelineService (integration)', function () {
128128
});
129129

130130
it('should fail and not create a download when linking an invalid feature ID', async () => {
131-
// Step 1: Use a savepoint so we can continue querying after the expected FK error
131+
// Step 1: Snapshot count before the attempt
132+
const before = await connection.sql(SQL`SELECT COUNT(*)::int as count FROM download;`);
133+
const countBefore = before.rows[0].count;
134+
135+
// Step 2: Use a savepoint so we can continue querying after the expected FK error
132136
// (PostgreSQL aborts the entire transaction on error without savepoints)
133137
await connection.query('SAVEPOINT before_fk_test');
134138

135-
// Step 2: Attempt to link a non-existent submission_feature_id
139+
// Step 3: Attempt to link a non-existent submission_feature_id
136140
try {
137141
await service.createDownloadRequest(null, [999999]);
138142
expect.fail('Should have thrown a foreign key violation');
@@ -141,13 +145,13 @@ describe('DownloadPipelineService (integration)', function () {
141145
expect(error).to.exist;
142146
}
143147

144-
// Step 3: Restore to savepoint so the transaction is usable again
148+
// Step 4: Restore to savepoint so the transaction is usable again
145149
await connection.query('ROLLBACK TO SAVEPOINT before_fk_test');
146150

147-
// Step 4: Verify no orphan download record was created
151+
// Step 5: Verify no orphan download record was created (count unchanged)
148152
const after = await connection.sql(SQL`SELECT COUNT(*)::int as count FROM download;`);
149153
const countAfter = after.rows[0].count;
150-
expect(countAfter).to.equal(0);
154+
expect(countAfter).to.equal(countBefore);
151155
});
152156
});
153157

api/src/constants/download.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export const FRAGMENT_SIZE_THRESHOLD = 500 * 1024 * 1024; // 500 MB per fragment
1+
export const FRAGMENT_SIZE_THRESHOLD = 200 * 1024 * 1024; // 200 MB per fragment
22
export const SIGNED_URL_EXPIRY_FRAGMENT = 432000; // 5 days for fragment URLs

api/src/models/cart.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export type CartWithFeatures = z.infer<typeof CartWithFeatures>;
3333
export const UpdateCart = z.object({
3434
system_user_id: z.number().nullable().optional(),
3535
cart_status: z.nativeEnum(CartStatus).optional(),
36-
record_end_date: z.string().nullable().optional()
36+
record_end_date: z.string().nullable().optional(),
37+
checkout_date: z.string().nullable().optional(),
38+
checkout_user: z.number().nullable().optional()
3739
});
3840

3941
export type UpdateCart = z.infer<typeof UpdateCart>;

api/src/models/download.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,16 @@ export const DownloadFeatureSummary = z.object({
4040
estimated_byte_size: z.string()
4141
});
4242
export type DownloadFeatureSummary = z.infer<typeof DownloadFeatureSummary>;
43+
44+
/**
45+
* Result of estimating a download's total size before processing.
46+
* Used by planFragments to decide how to split features across zip files.
47+
*
48+
* Per-feature and total sizes are computed inline in the SQL query.
49+
*/
50+
export interface DownloadSizeEstimate {
51+
/** Total estimated bytes across all features. */
52+
totalEstimatedBytes: number;
53+
/** The features included in this download with per-feature estimated_byte_size. */
54+
features: DownloadFeatureSummary[];
55+
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import chai, { expect } from 'chai';
2+
import { describe } from 'mocha';
3+
import sinon from 'sinon';
4+
import sinonChai from 'sinon-chai';
5+
import { checkoutCart } from '.';
6+
import * as db from '../../../../database/db';
7+
import { ApiError } from '../../../../errors/api-error';
8+
import { DownloadId } from '../../../../models/download';
9+
import { CartService } from '../../../../services/cart-service';
10+
import { getMockDBConnection, getRequestHandlerMocks } from '../../../../__mocks__/db';
11+
12+
chai.use(sinonChai);
13+
14+
describe('cart/{cartId}/checkout', () => {
15+
afterEach(() => {
16+
sinon.restore();
17+
});
18+
19+
describe('checkoutCart', () => {
20+
it('throws error if DB connection fails to open', async () => {
21+
const mockDBConnection = getMockDBConnection({
22+
commit: sinon.stub(),
23+
rollback: sinon.stub(),
24+
release: sinon.stub()
25+
});
26+
sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection);
27+
sinon.stub(mockDBConnection, 'open').rejects(new Error('DB open failed'));
28+
29+
const requestHandler = checkoutCart();
30+
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
31+
mockReq.params.cartId = 'fake-cart-id';
32+
mockReq.keycloak_token = null;
33+
34+
try {
35+
await requestHandler(mockReq, mockRes, mockNext);
36+
expect.fail('Expected handler to throw');
37+
} catch (error) {
38+
expect((error as ApiError).message).to.equal('DB open failed');
39+
expect(mockDBConnection.rollback).to.have.been.calledOnce;
40+
expect(mockDBConnection.release).to.have.been.calledOnce;
41+
}
42+
});
43+
44+
it('returns 201 with download_id for authenticated user', async () => {
45+
const mockDBConnection = getMockDBConnection({
46+
commit: sinon.stub(),
47+
rollback: sinon.stub(),
48+
release: sinon.stub(),
49+
systemUserId: sinon.stub().returns(42)
50+
});
51+
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
52+
53+
const fakeResult: DownloadId = { download_id: 'dl-uuid-1234' };
54+
const checkoutCartStub = sinon.stub(CartService.prototype, 'checkoutCart').resolves(fakeResult);
55+
56+
const requestHandler = checkoutCart();
57+
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
58+
mockReq.params.cartId = 'cart-uuid-5678';
59+
mockReq.keycloak_token = { sub: 'user-id' };
60+
61+
await requestHandler(mockReq, mockRes, mockNext);
62+
63+
expect(checkoutCartStub).to.have.been.calledOnceWith('cart-uuid-5678', 42, undefined);
64+
expect(mockDBConnection.commit).to.have.been.calledOnce;
65+
expect(mockDBConnection.release).to.have.been.calledOnce;
66+
expect(mockRes.statusValue).to.equal(201);
67+
expect(mockRes.jsonValue).to.eql(fakeResult);
68+
});
69+
70+
it('returns 201 with download_id for anonymous user', async () => {
71+
const mockDBConnection = getMockDBConnection({
72+
commit: sinon.stub(),
73+
rollback: sinon.stub(),
74+
release: sinon.stub()
75+
});
76+
const apiDBStub = sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection);
77+
78+
const fakeResult: DownloadId = { download_id: 'dl-uuid-anon' };
79+
const checkoutCartStub = sinon.stub(CartService.prototype, 'checkoutCart').resolves(fakeResult);
80+
81+
const requestHandler = checkoutCart();
82+
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
83+
mockReq.params.cartId = 'cart-uuid-anon';
84+
mockReq.keycloak_token = null;
85+
86+
await requestHandler(mockReq, mockRes, mockNext);
87+
88+
expect(apiDBStub).to.have.been.calledOnce;
89+
expect(checkoutCartStub).to.have.been.calledOnceWith('cart-uuid-anon', null, undefined);
90+
expect(mockDBConnection.commit).to.have.been.calledOnce;
91+
expect(mockDBConnection.release).to.have.been.calledOnce;
92+
expect(mockRes.statusValue).to.equal(201);
93+
expect(mockRes.jsonValue).to.eql(fakeResult);
94+
});
95+
96+
it('forwards fragment_size_bytes from request body to service', async () => {
97+
const mockDBConnection = getMockDBConnection({
98+
commit: sinon.stub(),
99+
rollback: sinon.stub(),
100+
release: sinon.stub(),
101+
systemUserId: sinon.stub().returns(42)
102+
});
103+
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
104+
105+
const fakeResult: DownloadId = { download_id: 'dl-uuid-frag' };
106+
const checkoutCartStub = sinon.stub(CartService.prototype, 'checkoutCart').resolves(fakeResult);
107+
108+
const requestHandler = checkoutCart();
109+
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
110+
mockReq.params.cartId = 'cart-uuid-frag';
111+
mockReq.keycloak_token = { sub: 'user-id' };
112+
mockReq.body = { fragment_size_bytes: 5242880 };
113+
114+
await requestHandler(mockReq, mockRes, mockNext);
115+
116+
expect(checkoutCartStub).to.have.been.calledOnceWith('cart-uuid-frag', 42, 5242880);
117+
expect(mockRes.statusValue).to.equal(201);
118+
});
119+
120+
it('rolls back and rethrows if CartService.checkoutCart throws', async () => {
121+
const mockDBConnection = getMockDBConnection({
122+
commit: sinon.stub(),
123+
rollback: sinon.stub(),
124+
release: sinon.stub(),
125+
systemUserId: sinon.stub().returns(42)
126+
});
127+
sinon.stub(db, 'getDBConnection').returns(mockDBConnection);
128+
sinon.stub(CartService.prototype, 'checkoutCart').rejects(new Error('Checkout failed'));
129+
130+
const requestHandler = checkoutCart();
131+
const { mockReq, mockRes, mockNext } = getRequestHandlerMocks();
132+
mockReq.params.cartId = 'cart-uuid-5678';
133+
mockReq.keycloak_token = { sub: 'user-id' };
134+
135+
try {
136+
await requestHandler(mockReq, mockRes, mockNext);
137+
expect.fail('Expected handler to throw');
138+
} catch (error) {
139+
expect((error as ApiError).message).to.equal('Checkout failed');
140+
expect(mockDBConnection.rollback).to.have.been.calledOnce;
141+
expect(mockDBConnection.release).to.have.been.calledOnce;
142+
}
143+
});
144+
});
145+
});

0 commit comments

Comments
 (0)