Skip to content

Commit ae768f2

Browse files
authored
SIMSBIOHUB-883: Parse Feature Content into Search Tables (#341)
* feat(queue): add index-submission-features job with idempotent indexing * feat(queue): chain indexing job from validation pipeline * fix(test): clean up search_ tables
1 parent 97e5dc7 commit ae768f2

15 files changed

+974
-5
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Integration test for SearchFeatureRepository.deleteSearchRecordsBySubmissionId —
2+
// verifies the subquery-based delete correctly removes records from all 4 search tables
3+
// for a given submission, without affecting records belonging to other submissions.
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 { SearchFeatureRepository } from '../../repositories/search-feature-repository';
14+
15+
describe('SearchFeatureRepository (integration)', function () {
16+
this.timeout(15000);
17+
18+
let connection: IDBConnection;
19+
let repo: SearchFeatureRepository;
20+
21+
before(() => {
22+
initDBPool(defaultPoolConfig);
23+
});
24+
25+
beforeEach(async () => {
26+
connection = getAPIUserDBConnection();
27+
await connection.open();
28+
repo = new SearchFeatureRepository(connection);
29+
});
30+
31+
afterEach(async () => {
32+
await connection.rollback();
33+
connection.release();
34+
});
35+
36+
/**
37+
* Helper: insert a minimal submission and return its ID.
38+
*/
39+
async function createTestSubmission(): Promise<number> {
40+
const systemUserId = connection.systemUserId();
41+
42+
const result = await connection.sql(SQL`
43+
INSERT INTO submission (uuid, system_user_id, source_system, name, description, comment, create_user)
44+
VALUES (gen_random_uuid(), ${systemUserId}, 'SIMS', 'Search Index Test', 'Test', 'Test', ${systemUserId})
45+
RETURNING submission_id;
46+
`);
47+
48+
return result.rows[0].submission_id;
49+
}
50+
51+
/**
52+
* Helper: insert a submission_feature and return its ID.
53+
*/
54+
async function createTestFeature(submissionId: number): Promise<number> {
55+
const systemUserId = connection.systemUserId();
56+
57+
const result = await connection.sql(SQL`
58+
INSERT INTO submission_feature (submission_id, feature_type_id, data, data_byte_size, create_user)
59+
VALUES (
60+
${submissionId},
61+
(SELECT feature_type_id FROM feature_type WHERE name = 'dataset' LIMIT 1),
62+
'{"name": "test"}'::jsonb,
63+
100,
64+
${systemUserId}
65+
)
66+
RETURNING submission_feature_id;
67+
`);
68+
69+
return result.rows[0].submission_feature_id;
70+
}
71+
72+
/**
73+
* Helper: insert a search_string record.
74+
*/
75+
async function insertSearchString(submissionFeatureId: number, featurePropertyId: number, value: string) {
76+
const systemUserId = connection.systemUserId();
77+
78+
await connection.sql(SQL`
79+
INSERT INTO search_string (submission_feature_id, feature_property_id, value, create_user)
80+
VALUES (${submissionFeatureId}, ${featurePropertyId}, ${value}, ${systemUserId});
81+
`);
82+
}
83+
84+
/**
85+
* Helper: insert a search_number record.
86+
*/
87+
async function insertSearchNumber(submissionFeatureId: number, featurePropertyId: number, value: number) {
88+
const systemUserId = connection.systemUserId();
89+
90+
await connection.sql(SQL`
91+
INSERT INTO search_number (submission_feature_id, feature_property_id, value, create_user)
92+
VALUES (${submissionFeatureId}, ${featurePropertyId}, ${value}, ${systemUserId});
93+
`);
94+
}
95+
96+
/**
97+
* Helper: count records in a search table for a given submission_feature_id.
98+
*/
99+
async function countSearchRecords(table: string, submissionFeatureId: number): Promise<number> {
100+
const result = await connection.sql(
101+
SQL`
102+
SELECT count(*)::integer as count
103+
FROM `.append(table).append(SQL`
104+
WHERE submission_feature_id = ${submissionFeatureId};
105+
`)
106+
);
107+
108+
return result.rows[0].count;
109+
}
110+
111+
describe('deleteSearchRecordsBySubmissionId', () => {
112+
it('deletes search_string and search_number records for the given submission', async () => {
113+
// Arrange: create submission with a feature and search records
114+
const submissionId = await createTestSubmission();
115+
const featureId = await createTestFeature(submissionId);
116+
117+
// feature_property_id 1 = site_identifier (string type), 6 = measurement_value (number type)
118+
await insertSearchString(featureId, 1, 'test-site');
119+
await insertSearchNumber(featureId, 6, 42);
120+
121+
// Verify records exist
122+
expect(await countSearchRecords('search_string', featureId)).to.equal(1);
123+
expect(await countSearchRecords('search_number', featureId)).to.equal(1);
124+
125+
// Act
126+
await repo.deleteSearchRecordsBySubmissionId(submissionId);
127+
128+
// Assert: all records deleted
129+
expect(await countSearchRecords('search_string', featureId)).to.equal(0);
130+
expect(await countSearchRecords('search_number', featureId)).to.equal(0);
131+
});
132+
133+
it('does not delete records belonging to a different submission', async () => {
134+
// Arrange: create two submissions with search records
135+
const submissionId1 = await createTestSubmission();
136+
const featureId1 = await createTestFeature(submissionId1);
137+
await insertSearchString(featureId1, 1, 'submission-1-value');
138+
139+
const submissionId2 = await createTestSubmission();
140+
const featureId2 = await createTestFeature(submissionId2);
141+
await insertSearchString(featureId2, 1, 'submission-2-value');
142+
143+
// Act: delete only submission 1's records
144+
await repo.deleteSearchRecordsBySubmissionId(submissionId1);
145+
146+
// Assert: submission 1 records gone, submission 2 records intact
147+
expect(await countSearchRecords('search_string', featureId1)).to.equal(0);
148+
expect(await countSearchRecords('search_string', featureId2)).to.equal(1);
149+
});
150+
151+
it('succeeds with no error when submission has no search records', async () => {
152+
const submissionId = await createTestSubmission();
153+
await createTestFeature(submissionId);
154+
155+
// Act: should not throw
156+
await repo.deleteSearchRecordsBySubmissionId(submissionId);
157+
});
158+
159+
it('deletes records across multiple features in the same submission', async () => {
160+
// Arrange: one submission, two features, each with search records
161+
const submissionId = await createTestSubmission();
162+
const featureId1 = await createTestFeature(submissionId);
163+
const featureId2 = await createTestFeature(submissionId);
164+
165+
await insertSearchString(featureId1, 1, 'feature-1-value');
166+
await insertSearchString(featureId2, 1, 'feature-2-value');
167+
await insertSearchNumber(featureId1, 6, 10);
168+
await insertSearchNumber(featureId2, 6, 20);
169+
170+
// Act
171+
await repo.deleteSearchRecordsBySubmissionId(submissionId);
172+
173+
// Assert: all records across both features deleted
174+
expect(await countSearchRecords('search_string', featureId1)).to.equal(0);
175+
expect(await countSearchRecords('search_string', featureId2)).to.equal(0);
176+
expect(await countSearchRecords('search_number', featureId1)).to.equal(0);
177+
expect(await countSearchRecords('search_number', featureId2)).to.equal(0);
178+
});
179+
});
180+
});

api/src/__integration__/system/process-submission-features-worker.integration.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ describe('Process Submission Features Worker', function () {
8989
try {
9090
// Clean up in reverse FK order
9191
for (const submissionId of createdSubmissionIds) {
92+
// search_ tables FK to submission_feature — delete first
93+
const featureIds = await db('biohub.submission_feature')
94+
.where('submission_id', submissionId)
95+
.select('submission_feature_id');
96+
const ids = featureIds.map((r: { submission_feature_id: number }) => r.submission_feature_id);
97+
if (ids.length) {
98+
await db('biohub.search_string').whereIn('submission_feature_id', ids).del();
99+
await db('biohub.search_number').whereIn('submission_feature_id', ids).del();
100+
await db('biohub.search_datetime').whereIn('submission_feature_id', ids).del();
101+
await db('biohub.search_spatial').whereIn('submission_feature_id', ids).del();
102+
}
92103
await db('biohub.submission_feature').where('submission_id', submissionId).del();
93104
await db('biohub.submission_validation').where('submission_id', submissionId).del();
94105
await db('biohub.submission_upload').where('submission_id', submissionId).del();
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import chai, { expect } from 'chai';
2+
import { describe } from 'mocha';
3+
import PgBoss from 'pg-boss';
4+
import sinon from 'sinon';
5+
import sinonChai from 'sinon-chai';
6+
import * as db from '../../database/db';
7+
import { SearchFeatureService } from '../../services/search-feature-service';
8+
import { getMockDBConnection } from '../../__mocks__/db';
9+
import {
10+
IIndexSubmissionFeaturesJobData,
11+
indexSubmissionFeaturesFailedHandler,
12+
indexSubmissionFeaturesJobHandler
13+
} from './index-submission-features-job';
14+
15+
chai.use(sinonChai);
16+
17+
describe('indexSubmissionFeaturesJobHandler', () => {
18+
afterEach(() => {
19+
sinon.restore();
20+
});
21+
22+
const createMockJob = (submissionId: number, id = 'job-1'): PgBoss.Job<IIndexSubmissionFeaturesJobData> =>
23+
({
24+
id,
25+
name: 'index-submission-features',
26+
data: { submissionId }
27+
} as PgBoss.Job<IIndexSubmissionFeaturesJobData>);
28+
29+
it('should index submission successfully', async () => {
30+
const mockDBConnection = getMockDBConnection();
31+
const openStub = sinon.stub().resolves();
32+
const commitStub = sinon.stub().resolves();
33+
const releaseStub = sinon.stub();
34+
mockDBConnection.open = openStub;
35+
mockDBConnection.commit = commitStub;
36+
mockDBConnection.release = releaseStub;
37+
38+
sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection);
39+
40+
const indexStub = sinon.stub(SearchFeatureService.prototype, 'indexFeaturesBySubmissionId').resolves();
41+
42+
await indexSubmissionFeaturesJobHandler([createMockJob(777)]);
43+
44+
expect(indexStub).to.have.been.calledOnceWith(777);
45+
expect(commitStub).to.have.been.calledOnce;
46+
expect(releaseStub).to.have.been.calledOnce;
47+
});
48+
49+
it('should roll back and throw on indexing failure', async () => {
50+
const mockDBConnection = getMockDBConnection();
51+
const rollbackStub = sinon.stub().resolves();
52+
const releaseStub = sinon.stub();
53+
mockDBConnection.open = sinon.stub().resolves();
54+
mockDBConnection.rollback = rollbackStub;
55+
mockDBConnection.release = releaseStub;
56+
57+
sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection);
58+
59+
const testError = new Error('Indexing failed');
60+
sinon.stub(SearchFeatureService.prototype, 'indexFeaturesBySubmissionId').rejects(testError);
61+
62+
try {
63+
await indexSubmissionFeaturesJobHandler([createMockJob(777)]);
64+
expect.fail('Should have thrown an error');
65+
} catch (error) {
66+
expect((error as Error).message).to.equal('Indexing failed');
67+
}
68+
69+
expect(rollbackStub).to.have.been.calledOnce;
70+
expect(releaseStub).to.have.been.calledOnce;
71+
});
72+
73+
it('should process multiple jobs in sequence', async () => {
74+
const openStub = sinon.stub().resolves();
75+
const commitStub = sinon.stub().resolves();
76+
const releaseStub = sinon.stub();
77+
78+
const mockDBConnection = getMockDBConnection();
79+
mockDBConnection.open = openStub;
80+
mockDBConnection.commit = commitStub;
81+
mockDBConnection.release = releaseStub;
82+
83+
sinon.stub(db, 'getAPIUserDBConnection').returns(mockDBConnection);
84+
85+
const indexStub = sinon.stub(SearchFeatureService.prototype, 'indexFeaturesBySubmissionId').resolves();
86+
87+
await indexSubmissionFeaturesJobHandler([createMockJob(1, 'job-1'), createMockJob(2, 'job-2')]);
88+
89+
expect(indexStub.callCount).to.equal(2);
90+
expect(openStub.callCount).to.equal(2);
91+
expect(commitStub.callCount).to.equal(2);
92+
expect(releaseStub.callCount).to.equal(2);
93+
});
94+
95+
it('should handle empty jobs array', async () => {
96+
const getConnectionStub = sinon.stub(db, 'getAPIUserDBConnection');
97+
98+
await indexSubmissionFeaturesJobHandler([]);
99+
100+
expect(getConnectionStub).not.to.have.been.called;
101+
});
102+
});
103+
104+
describe('indexSubmissionFeaturesFailedHandler', () => {
105+
afterEach(() => {
106+
sinon.restore();
107+
});
108+
109+
it('should log failure with error output without throwing', async () => {
110+
const getConnectionStub = sinon.stub(db, 'getAPIUserDBConnection');
111+
112+
const job = {
113+
id: 'job-1',
114+
name: 'index-submission-features-failed',
115+
data: { submissionId: 777 },
116+
output: { message: 'Indexing failed after retries' }
117+
} as unknown as PgBoss.Job<IIndexSubmissionFeaturesJobData>;
118+
119+
await indexSubmissionFeaturesFailedHandler([job]);
120+
121+
// DLQ handler is log-only — no DB connection should be opened
122+
expect(getConnectionStub).not.to.have.been.called;
123+
});
124+
125+
it('should log default message when output is null', async () => {
126+
const getConnectionStub = sinon.stub(db, 'getAPIUserDBConnection');
127+
128+
const job = {
129+
id: 'job-2',
130+
name: 'index-submission-features-failed',
131+
data: { submissionId: 888 },
132+
output: null
133+
} as unknown as PgBoss.Job<IIndexSubmissionFeaturesJobData>;
134+
135+
await indexSubmissionFeaturesFailedHandler([job]);
136+
137+
expect(getConnectionStub).not.to.have.been.called;
138+
});
139+
});

0 commit comments

Comments
 (0)