Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 1 addition & 10 deletions api/__tests__/buildCollectionSearchQuery.aggregates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ describe('buildCollectionSearchQuery - aggregated fields', () => {
// Core collection fields should be prefixed with 'c.'
expect(sql).toMatch(/c\.id/);
expect(sql).toMatch(/c\.stac_version/);
expect(sql).toMatch(/c\.type/);
expect(sql).toMatch(/c\.title/);
expect(sql).toMatch(/c\.description/);
expect(sql).toMatch(/c\.license/);
Expand All @@ -30,7 +29,6 @@ describe('buildCollectionSearchQuery - aggregated fields', () => {
expect(sql).toMatch(/prov\.providers/);
expect(sql).toMatch(/a\.assets/);
expect(sql).toMatch(/s\.summaries/);
expect(sql).toMatch(/cl\.last_crawled/);
});

test('FROM clause uses collection alias c', () => {
Expand Down Expand Up @@ -94,13 +92,6 @@ describe('buildCollectionSearchQuery - aggregated fields', () => {
expect(sql).toMatch(/WHERE cs\.collection_id = c\.id/);
});

test('includes LATERAL JOIN for last_crawled timestamp', () => {
const { sql } = buildCollectionSearchQuery({ limit: 10, token: 0 });

expect(sql).toMatch(/MAX\(clc\.last_crawled\) AS last_crawled/);
expect(sql).toMatch(/FROM crawllog_collection clc/);
expect(sql).toMatch(/WHERE clc\.collection_id = c\.id/);
});
});

describe('WHERE clauses use collection alias c', () => {
Expand Down Expand Up @@ -214,7 +205,7 @@ describe('buildCollectionSearchQuery - aggregated fields', () => {
// Count LEFT JOIN LATERAL occurrences (should be 6: kw, ext, prov, a, s, cl)
const leftJoinLateralCount = (sql.match(/LEFT JOIN LATERAL/gi) || []).length;

expect(leftJoinLateralCount).toBe(6);
expect(leftJoinLateralCount).toBe(5);
});
});
});
16 changes: 0 additions & 16 deletions api/__tests__/buildCollectionSearchQuery.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ describe('Integration: Collection Search with Aggregated Fields', () => {
expect(firstRow).toHaveProperty('providers');
expect(firstRow).toHaveProperty('assets');
expect(firstRow).toHaveProperty('summaries');
expect(firstRow).toHaveProperty('last_crawled');
}
});
});
Expand Down Expand Up @@ -138,19 +137,6 @@ describe('Integration: Collection Search with Aggregated Fields', () => {
}
});
});

test('last_crawled should be timestamp or null', async () => {
const { sql, values } = buildCollectionSearchQuery({ limit: 10, token: 0 });
const result = await query(sql, values);

result.rows.forEach(row => {
if (row.last_crawled !== null) {
// Should be a valid Date or parseable timestamp
const date = new Date(row.last_crawled);
expect(date.toString()).not.toBe('Invalid Date');
}
});
});
});

describe('Filter Compatibility with Aggregated Fields', () => {
Expand Down Expand Up @@ -179,7 +165,6 @@ describe('Integration: Collection Search with Aggregated Fields', () => {
result.rows.forEach(row => {
expect(row).toHaveProperty('stac_extensions');
expect(row).toHaveProperty('summaries');
expect(row).toHaveProperty('last_crawled');
});
});

Expand Down Expand Up @@ -212,7 +197,6 @@ describe('Integration: Collection Search with Aggregated Fields', () => {
expect(row).toHaveProperty('providers');
expect(row).toHaveProperty('assets');
expect(row).toHaveProperty('summaries');
expect(row).toHaveProperty('last_crawled');
});
});
});
Expand Down
1 change: 0 additions & 1 deletion api/__tests__/cql2.integration.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,6 @@ describe('CQL2 Filter Integration Tests', () => {
'providers': 'prov.providers',
'assets': 'a.assets',
'summaries': 's.summaries',
'last_crawled': 'cl.last_crawled'
};

Object.entries(mappings).forEach(([prop, expected]) => {
Expand Down
1 change: 0 additions & 1 deletion api/__tests__/cql2ToSql.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,6 @@ describe('cql2ToSql', () => {
'providers': 'prov.providers',
'assets': 'a.assets',
'summaries': 's.summaries',
'last_crawled': 'cl.last_crawled'
};

Object.entries(mappings).forEach(([prop, expected]) => {
Expand Down
103 changes: 103 additions & 0 deletions api/__tests__/health.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
const request = require('supertest');
const express = require('express');

// Mock DB module BEFORE importing the router
jest.mock('../db/db_APIconnection', () => ({
ping: jest.fn(),
query: jest.fn(),
pool: { connect: jest.fn() },
}));

const db = require('../db/db_APIconnection');
const healthRouter = require('../routes/health');

describe('Health Check Endpoint', () => {
let app;

beforeEach(() => {
process.env.SERVICE_NAME = 'STAC Atlas API';
db.ping.mockResolvedValue({ ok: true });

app = express();
app.use('/health', healthRouter);
});

afterEach(() => {
jest.clearAllMocks();
delete process.env.SERVICE_NAME;
});

test('GET /health returns 200 status code when DB is ok', async () => {
const response = await request(app).get('/health');
expect(response.status).toBe(200);
});

test('GET /health returns json content type', async () => {
const response = await request(app).get('/health');
expect(response.type).toBe('application/json');
});

test('GET /health response contains STAC-compliant structure', async () => {
const response = await request(app).get('/health');
expect(response.body.type).toBe('Health');
expect(response.body.id).toBe('stac-atlas-health');
expect(response.body.title).toBe('STAC Atlas API Health Check');
expect(response.body.description).toBeDefined();
expect(typeof response.body.description).toBe('string');
});

test('GET /health response contains liveness + readiness fields', async () => {
const response = await request(app).get('/health');
expect(response.body.status).toBe('ok');
expect(response.body.ready).toBe(true);
expect(response.body.checks.alive.status).toBe('ok');
expect(response.body.checks.db.status).toBe('ok');
expect(typeof response.body.checks.db.latencyMs).toBe('number');
});

test('GET /health response contains timestamp in ISO format', async () => {
const response = await request(app).get('/health');
expect(response.body.timestamp).toBeDefined();
expect(new Date(response.body.timestamp).toISOString()).toBe(response.body.timestamp);
});

test('GET /health response contains uptime', async () => {
const response = await request(app).get('/health');
expect(response.body.uptimeSec).toBeDefined();
expect(typeof response.body.uptimeSec).toBe('number');
expect(response.body.uptimeSec).toBeGreaterThanOrEqual(0);
});

test('GET /health response contains STAC links', async () => {
const response = await request(app).get('/health');
expect(response.body.links).toBeDefined();
expect(Array.isArray(response.body.links)).toBe(true);
expect(response.body.links.length).toBeGreaterThan(0);

// Check for required link relations
const linkRels = response.body.links.map(link => link.rel);
expect(linkRels).toContain('self');
expect(linkRels).toContain('root');
expect(linkRels).toContain('parent');

// Validate link structure
response.body.links.forEach(link => {
expect(link).toHaveProperty('rel');
expect(link).toHaveProperty('href');
expect(link).toHaveProperty('type');
expect(link).toHaveProperty('title');
expect(typeof link.href).toBe('string');
expect(link.href.length).toBeGreaterThan(0);
});
});

test('GET /health returns 503 when DB ping fails', async () => {
db.ping.mockResolvedValue({ ok: false, code: 'ECONN', message: 'nope' });

const response = await request(app).get('/health');
expect(response.status).toBe(503);
expect(response.body.status).toBe('degraded');
expect(response.body.ready).toBe(false);
expect(response.body.checks.db.status).toBe('error');
});
});
2 changes: 2 additions & 0 deletions api/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const indexRouter = require('./routes/index');
const conformanceRouter = require('./routes/conformance');
const collectionsRouter = require('./routes/collections');
const queryablesRouter = require('./routes/queryables');
const healthRouter = require('./routes/health');

const app = express();

Expand Down Expand Up @@ -77,6 +78,7 @@ app.use('/', indexRouter);
app.use('/conformance', conformanceRouter);
app.use('/collections', collectionsRouter);
app.use('/collections-queryables', queryablesRouter);
app.use('/health', healthRouter);

// 404 handler - must be after all routes
app.use((req, res, next) => {
Expand Down
9 changes: 0 additions & 9 deletions api/config/queryablesSchema.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,15 +245,6 @@ function buildCollectionsQueryablesSchema(baseUrl) {
'x-implementation-status': 'Summary filtering requires JSONB key/value logic (not yet implemented).'
},

last_crawled: {
title: 'Last Crawled',
description: 'Timestamp of last crawler visit. Maps to cl.last_crawled from LATERAL JOIN.',
type: 'string',
format: 'date-time',
'x-ogc-operators': OPS_TIMESTAMP,
'x-ogc-property': 'cl.last_crawled'
},

// ==================== Property Aliases ====================

created: {
Expand Down
9 changes: 1 addition & 8 deletions api/db/buildCollectionSearchQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,6 @@ function buildCollectionSearchQuery(params) {
c.stac_version,
c.stac_id,
c.source_url,
c.type,
c.title,
c.description,
c.license,
Expand All @@ -126,8 +125,7 @@ function buildCollectionSearchQuery(params) {
ext.stac_extensions,
prov.providers,
a.assets,
s.summaries,
cl.last_crawled
s.summaries
`;

const where = [];
Expand Down Expand Up @@ -311,11 +309,6 @@ function buildCollectionSearchQuery(params) {
WHERE cs.collection_id = c.id
) s
) s ON TRUE
LEFT JOIN LATERAL (
SELECT MAX(clc.last_crawled) AS last_crawled
FROM crawllog_collection clc
WHERE clc.collection_id = c.id
) cl ON TRUE
`;

if (where.length > 0) {
Expand Down
16 changes: 16 additions & 0 deletions api/db/db_APIconnection.js
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,21 @@ async function testConnection(retries = 3, delay = 2000) {
return false;
}

// simple ping to check connicivity (used in health check)
async function ping() {
let client;
try {
client = await pool.connect();
await client.query('BEGIN');
await client.query('ROLLBACK');
return { ok: true };
} catch (err) {
return { ok: false, code: err.code, message: err.message };
} finally {
if (client) client.release(); // release client back to pool --> no leaks
}
}

// Get current pool statistics
function getPoolStats() {
return {
Expand Down Expand Up @@ -260,6 +275,7 @@ module.exports = {
testConnection,
closePool,
getPoolStats,
ping,

// PostGIS functions
queryByBBox,
Expand Down
1 change: 0 additions & 1 deletion api/docs/cql2-filtering.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,6 @@ The following properties can be used in CQL2 filter expressions:
| `providers` | Array | Data providers |
| `assets` | Array | Collection assets |
| `summaries` | Object | Property summaries |
| `last_crawled` | Timestamp | Last crawler update |

### Aliases

Expand Down
2 changes: 1 addition & 1 deletion api/routes/collections.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ function toStacCollection(row, baseHost) {
collection.id = row.stac_id;
collection.stac_id = row.stac_id;

// TODO: Add is_active, is_api, last_crawled fields if needed
// TODO: Add is_active, is_api, fields if needed

// Add Links incase a baseHost is provided
if (baseHost !== undefined) {
Expand Down
Loading
Loading