diff --git a/CHANGELOG.md b/CHANGELOG.md index 25bd7053..f4d6ce41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- Automatic temporal extent calculation for collections. When serving collections via the `/collections` + and `/collections/{collectionId}` endpoints, if a collection does not have a temporal extent defined, + the server will automatically calculate it from the earliest and latest items in the collection. To use + this feature, simply omit the `extent.temporal.interval` field when ingesting a collection. - Asset proxying for generating pre-signed S3 URLs through proxy endpoints `GET /collections/{collectionId}/items/{itemId}/assets/{assetKey}` and `GET /collections/{collectionId}/assets/{assetKey}`. diff --git a/README.md b/README.md index 484a2b4f..e284d49a 100644 --- a/README.md +++ b/README.md @@ -1496,17 +1496,24 @@ One limitation of the header approach is that API Gateway has a hard limit of 10 ## Ingesting Data -STAC Collections and Items are ingested by the `ingest` Lambda function, however this Lambda is not invoked directly by a user, it consumes records from the `stac-server--queue` SQS. To add STAC Items or Collections to the queue, publish them to the SNS Topic `stac-server--ingest`. +STAC Collections and Items are ingested by the `ingest` Lambda function, however this Lambda is not invoked directly + by a user, it consumes records from the `stac-server--queue` SQS. To add STAC Items or Collections to the + queue, publish them to the SNS Topic `stac-server--ingest`. -**STAC Collections must be ingested before Items that belong to that Collection.** Items should have the `collection` field populated with the ID of an existing Collection. If an Item is ingested before ingestion of the Collection it contains, -ingestion will either fail (in the case of a single Item ingest) or if auto-creation of indexes is enabled (default) and multiple Items are ingested in bulk, the auto-created index will have incorrect mappings. +**STAC Collections must be ingested before Items that belong to that Collection.** Items should have the `collection` +field populated with the ID of an existing Collection. If an Item is ingested before ingestion of the Collection it contains, +ingestion will either fail (in the case of a single Item ingest) or if auto-creation of indexes is enabled (default) +and multiple Items are ingested in bulk, the auto-created index will have incorrect mappings. -If a collection or item is ingested, and an item with that id already exists in STAC, the new item will completely replace the old item, except the `created` property will be retained and the `updated` property updated +If a collection or item is ingested, and an item with that id already exists in STAC, the new item will completely +replace the old item, except the `created` property will be retained and the `updated` property updated to match the time of the new update. -After a collection or item is ingested, the status of the ingest (success or failure) along with details of the collection or item are sent to a post-ingest SNS topic. To take action on items after they are ingested subscribe an endpoint to this topic. +After a collection or item is ingested, the status of the ingest (success or failure) along with details of the +collection or item are sent to a post-ingest SNS topic. To take action on items after they are +ingested subscribe an endpoint to this topic. -Messages published to the post-ingest SNS topic include the following atributes that can be used for filtering: +Messages published to the post-ingest SNS topic include the following attributes that can be used for filtering: | attribute | type | values | | ------------ | ------ | ------------------------ | @@ -1514,6 +1521,20 @@ Messages published to the post-ingest SNS topic include the following atributes | ingestStatus | String | `successful` or `failed` | | collection | String | | +### Automatic Temporal Extent + +When ingesting Collections, the `extent.temporal.interval` field can be omitted to enable automatic temporal +extent calculation. When a collection is requested via the API, if it doesn't have a temporal extent defined, +stac-server will automatically calculate it by finding the earliest and latest `datetime` values from the items +in that collection. Collections with no items will have a temporal extent of `[[null, null]]`. This feature allows +temporal extents to stay current as items are added or removed without requiring manual collection updates. +The temporal extent is calculated dynamically each time the collection is requested, so it automatically reflects +the current state of items without requiring collection updates or persisting changes to the collection document. + +After a collection or item is ingested, the status of the ingest (success or failure) along with details of the c +ollection or item are sent to a post-ingest SNS topic. To take action on items after they are ingested + subscribe an endpoint to this topic. + ### Ingest actions In addition to ingesting Item and Collection JSON, the ingestion pipeline can also execute diff --git a/package-lock.json b/package-lock.json index 39a67075..3a5daf28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19135,6 +19135,43 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dev": true, + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@isaacs/balanced-match": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", @@ -19361,9 +19398,9 @@ } }, "node_modules/@messageformat/runtime": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.1.tgz", - "integrity": "sha512-6RU5ol2lDtO8bD9Yxe6CZkl0DArdv0qkuoZC+ZwowU+cdRlVE1157wjCmlA5Rsf1Xc/brACnsZa5PZpEDfTFFg==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@messageformat/runtime/-/runtime-3.0.2.tgz", + "integrity": "sha512-dkIPDCjXcfhSHgNE1/qV6TeczQZR59Yx0xXeafVKgK3QVWoxc38ljwpksUpnzCGvN151KUbCJTDZVmahtf1YZw==", "dev": true, "dependencies": { "make-plural": "^7.0.0" @@ -20082,14 +20119,13 @@ "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, "node_modules/@redocly/cli/node_modules/glob": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-11.0.3.tgz", - "integrity": "sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==", - "license": "ISC", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz", + "integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==", "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", - "minimatch": "^10.0.3", + "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" @@ -20150,10 +20186,9 @@ } }, "node_modules/@redocly/cli/node_modules/minimatch": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.0.3.tgz", - "integrity": "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==", - "license": "ISC", + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", + "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", "dependencies": { "@isaacs/brace-expansion": "^5.0.0" }, @@ -20606,9 +20641,9 @@ } }, "node_modules/@serverless/platform-client/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -24829,14 +24864,13 @@ } }, "node_modules/axios": { - "version": "1.8.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.8.4.tgz", - "integrity": "sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", + "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==", "dev": true, - "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -25502,9 +25536,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.7.0.tgz", - "integrity": "sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.1.tgz", + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==", "dev": true }, "node_modules/child-process-ext": { @@ -28286,20 +28320,6 @@ "node": ">=4" } }, - "node_modules/external-editor": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/external-editor/-/external-editor-3.1.0.tgz", - "integrity": "sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew==", - "dev": true, - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -28981,11 +29001,10 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, - "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", @@ -29540,16 +29559,16 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/inquirer": { - "version": "8.2.6", - "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.6.tgz", - "integrity": "sha512-M1WuAmb7pn9zdFRtQYk26ZBoY043Sse0wVDdk4Bppr+JOXyQYybdtvK+l9wUibhtjdjvtoiNy8tk+EgsYIUqKg==", + "version": "8.2.7", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-8.2.7.tgz", + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", "dev": true, "dependencies": { + "@inquirer/external-editor": "^1.0.0", "ansi-escapes": "^4.2.1", "chalk": "^4.1.1", "cli-cursor": "^3.1.0", "cli-width": "^3.0.0", - "external-editor": "^3.0.3", "figures": "^3.0.0", "lodash": "^4.17.21", "mute-stream": "0.0.8", @@ -30636,9 +30655,9 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "dependencies": { "argparse": "^2.0.1" }, @@ -30799,9 +30818,9 @@ } }, "node_modules/json-refs/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -32766,15 +32785,6 @@ "node": ">=8" } }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", - "integrity": "sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/outdent": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", @@ -36030,9 +36040,9 @@ } }, "node_modules/supertap/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "dependencies": { "argparse": "^1.0.7", @@ -36484,18 +36494,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tmp": { - "version": "0.0.33", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", - "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", - "dev": true, - "dependencies": { - "os-tmpdir": "~1.0.2" - }, - "engines": { - "node": ">=0.6.0" - } - }, "node_modules/to-buffer": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.1.1.tgz", diff --git a/src/lib/api.js b/src/lib/api.js index f28dcf6e..aa505cf9 100644 --- a/src/lib/api.js +++ b/src/lib/api.js @@ -1254,6 +1254,35 @@ const deleteUnusedFields = (collection) => { delete collection.aggregations } +/** + * Populate temporal extent for a collection from its items if not already defined + * @param {Object} backend - Database backend + * @param {Object} collection - Collection object + * @returns {Promise} + */ +const populateTemporalExtentIfMissing = async (backend, collection) => { + const id = collection.id + + // Check if collection already has a temporal extent defined + const start = collection.extent?.temporal?.interval?.[0]?.[0] + const end = collection.extent?.temporal?.interval?.[0]?.[1] + const hasTemporalExtent = start != null || end != null + + if (!hasTemporalExtent) { + const temporalExtent = await backend.getTemporalExtentFromItems(id) + if (temporalExtent) { + // Initialize extent structure if it doesn't exist + if (!collection.extent) { + collection.extent = {} + } + if (!collection.extent.temporal) { + collection.extent.temporal = {} + } + collection.extent.temporal.interval = temporalExtent + } + } +} + const getCollections = async function (backend, endpoint, parameters, headers) { // TODO: implement proper pagination, as this will only return up to // COLLECTION_LIMIT collections @@ -1267,6 +1296,10 @@ const getCollections = async function (backend, endpoint, parameters, headers) { (c) => isCollectionIdAllowed(allowedCollectionIds, c.id) ) + // Populate temporal extent for each collection from items only if not already defined + await Promise.all(collections.map((collection) => + populateTemporalExtentIfMissing(backend, collection))) + for (const collection of collections) { deleteUnusedFields(collection) } @@ -1313,6 +1346,9 @@ const getCollection = async function (backend, collectionId, endpoint, parameter return new NotFoundError() } + // Populate temporal extent from items only if not already defined + await populateTemporalExtentIfMissing(backend, result) + deleteUnusedFields(result) addCollectionLinks([result], endpoint) diff --git a/src/lib/database.js b/src/lib/database.js index 7ca75654..cc227046 100644 --- a/src/lib/database.js +++ b/src/lib/database.js @@ -1027,6 +1027,69 @@ async function healthCheck() { return client.cat.health() } +/** + * Calculate temporal extent for a collection by finding the earliest and latest items + * @param {string} collectionId - The collection ID + * @returns {Promise} Returns [[startDate, endDate]] or null if no items/datetime + */ +async function getTemporalExtentFromItems(collectionId) { + try { + const client = await _client() + if (client === undefined) throw new Error('Client is undefined') + + // Get earliest item by sorting ascending + const minParams = await constructSearchParams( + { collections: [collectionId] }, + undefined, + 1 // Only need the first item + ) + minParams.body.sort = [{ 'properties.datetime': { order: 'asc' } }] + minParams.body._source = ['properties.datetime'] + + // Get latest item by sorting descending + const maxParams = await constructSearchParams( + { collections: [collectionId] }, + undefined, + 1 // Only need the first item + ) + maxParams.body.sort = [{ 'properties.datetime': { order: 'desc' } }] + maxParams.body._source = ['properties.datetime'] + + // Execute both queries in parallel + const [minResponse, maxResponse] = await Promise.all([ + client.search({ + ignore_unavailable: true, + allow_no_indices: true, + ...minParams + }), + client.search({ + ignore_unavailable: true, + allow_no_indices: true, + ...maxParams + }) + ]) + + const minItem = minResponse.body.hits.hits[0]?._source + const maxItem = maxResponse.body.hits.hits[0]?._source + + // If no items or no datetime values, return [[null, null]] + if (!minItem?.properties?.datetime || !maxItem?.properties?.datetime) { + return [[null, null]] + } + + const startDate = minItem.properties.datetime + const endDate = maxItem.properties.datetime + + return [[startDate, endDate]] + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + logger.error( + `Error calculating temporal extent for collection ${collectionId}: ${errorMessage}` + ) + return null + } +} + export default { getCollections, getCollection, @@ -1042,5 +1105,6 @@ export default { aggregate, constructSearchParams, buildDatetimeQuery, - healthCheck + healthCheck, + getTemporalExtentFromItems } diff --git a/tests/system/test-api-temporal-extent.js b/tests/system/test-api-temporal-extent.js new file mode 100644 index 00000000..2154fd29 --- /dev/null +++ b/tests/system/test-api-temporal-extent.js @@ -0,0 +1,150 @@ +// @ts-nocheck + +import test from 'ava' +import { deleteAllIndices, refreshIndices } from '../helpers/database.js' +import { ingestItem } from '../helpers/ingest.js' +import { randomId, loadFixture } from '../helpers/utils.js' +import { setup } from '../helpers/system-tests.js' + +test.before(async (t) => { + await deleteAllIndices() + const standUpResult = await setup() + + t.context = standUpResult + t.context.collectionId = randomId('collection') + + const collection = await loadFixture( + 'landsat-8-l1-collection.json', + { id: t.context.collectionId } + ) + + await ingestItem({ + ingestQueueUrl: t.context.ingestQueueUrl, + ingestTopicArn: t.context.ingestTopicArn, + item: collection + }) + + // Ingest items with different dates + const item1 = await loadFixture('stac/LC80100102015002LGN00.json', { + collection: t.context.collectionId, + properties: { + datetime: '2015-01-02T15:49:05.000Z' + } + }) + + const item2 = await loadFixture('stac/LC80100102015002LGN00.json', { + collection: t.context.collectionId, + id: 'item-2', + properties: { + datetime: '2020-06-15T10:30:00.000Z' + } + }) + + const item3 = await loadFixture('stac/LC80100102015002LGN00.json', { + collection: t.context.collectionId, + id: 'item-3', + properties: { + datetime: '2018-03-20T08:15:00.000Z' + } + }) + + await ingestItem({ + ingestQueueUrl: t.context.ingestQueueUrl, + ingestTopicArn: t.context.ingestTopicArn, + item: item1 + }) + + await ingestItem({ + ingestQueueUrl: t.context.ingestQueueUrl, + ingestTopicArn: t.context.ingestTopicArn, + item: item2 + }) + + await ingestItem({ + ingestQueueUrl: t.context.ingestQueueUrl, + ingestTopicArn: t.context.ingestTopicArn, + item: item3 + }) + + await refreshIndices() +}) + +test.after.always(async (t) => { + if (t.context.api) await t.context.api.close() +}) + +test('GET /collections/:collectionId returns temporal extent from items', async (t) => { + const { collectionId } = t.context + + const response = await t.context.api.client.get(`collections/${collectionId}`, + { resolveBodyOnly: false }) + + t.is(response.statusCode, 200) + t.is(response.body.id, collectionId) + + // Check that extent.temporal.interval exists and is populated + t.truthy(response.body.extent) + t.truthy(response.body.extent.temporal) + t.truthy(response.body.extent.temporal.interval) + t.is(response.body.extent.temporal.interval.length, 1) + + const [startDate, endDate] = response.body.extent.temporal.interval[0] + + // Verify the start date is the earliest item datetime (2015-01-02) + t.is(startDate, '2015-01-02T15:49:05.000Z') + + // Verify the end date is the latest item datetime (2020-06-15) + t.is(endDate, '2020-06-15T10:30:00.000Z') +}) + +test('GET /collections returns temporal extent for all collections', async (t) => { + const response = await t.context.api.client.get('collections', + { resolveBodyOnly: false }) + + t.is(response.statusCode, 200) + t.truthy(response.body.collections) + t.true(response.body.collections.length > 0) + + // Find our test collection + const collection = response.body.collections.find((c) => c.id === t.context.collectionId) + t.truthy(collection) + + // Check that extent.temporal.interval exists and is populated + t.truthy(collection.extent) + t.truthy(collection.extent.temporal) + t.truthy(collection.extent.temporal.interval) + t.is(collection.extent.temporal.interval.length, 1) + + const [startDate, endDate] = collection.extent.temporal.interval[0] + + // Verify the dates match the items + t.is(startDate, '2015-01-02T15:49:05.000Z') + t.is(endDate, '2020-06-15T10:30:00.000Z') +}) + +test('Collection with no items has null temporal extent', async (t) => { + // Create a new collection with no items + const emptyCollectionId = randomId('empty-collection') + const emptyCollection = await loadFixture( + 'landsat-8-l1-collection.json', + { id: emptyCollectionId } + ) + + await ingestItem({ + ingestQueueUrl: t.context.ingestQueueUrl, + ingestTopicArn: t.context.ingestTopicArn, + item: emptyCollection + }) + + await refreshIndices() + + const response = await t.context.api.client.get(`collections/${emptyCollectionId}`, + { resolveBodyOnly: false }) + + t.is(response.statusCode, 200) + t.is(response.body.id, emptyCollectionId) + + // For a collection with no items, temporal extent should still exist from the original collection + // but our code should gracefully handle this (return null or keep original) + t.truthy(response.body.extent) +})