diff --git a/cloudflare-workers/README.md b/cloudflare-workers/README.md index dad464d..620cdd4 100644 --- a/cloudflare-workers/README.md +++ b/cloudflare-workers/README.md @@ -1,6 +1,6 @@ # Cloudflare Workers API for Couchbase -This project demonstrates how to build a Cloudflare Workers-based API using the **Hono framework** that interfaces with Couchbase's Data API to manage airport data from the travel-sample dataset. +This project demonstrates how to build a Cloudflare Workers-based API using the [**Hono framework**](https://developers.cloudflare.com/workers/frameworks/framework-guides/hono/) that interfaces with Couchbase's Data API to manage airport data from the travel-sample dataset. ## Overview @@ -13,12 +13,11 @@ The API provides a comprehensive Airport Information System that manages airport - `DELETE /airports/{document_key}` - Delete an airport document ### Airport Information Queries -- `POST /airports/routes` - Find routes for a specific airport -- `POST /airports/airlines` - Find airlines that service a specific airport +- `GET /airports/{airport_code}/routes` - Find routes for a specific airport +- `GET /airports/{airport_code}/airlines` - Find airlines that service a specific airport ### Full Text Search (FTS) Features -- `POST /fts/index/create` - Create FTS index for hotel geo-spatial search -- `POST /airports/hotels/nearby` - Find hotels near a specific airport using geo-spatial FTS +- `GET /airports/{airport_id}/hotels/nearby/{distance}` - Find hotels near a specific airport within a specific distance **Note:** The FTS features require: 1. A Full Text Search index with geo-spatial mapping on hotel documents @@ -27,10 +26,11 @@ The API provides a comprehensive Airport Information System that manages airport ## Prerequisites -- [Node.js](https://nodejs.org/) (v18 or later) +- [Node.js](https://nodejs.org/) (v20 or later) - [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) -- Couchbase Server with Data API enabled -- Couchbase travel-sample bucket loaded +- [Cloudflare account](https://dash.cloudflare.com/) with verified email +- [Couchbase Capella](https://www.couchbase.com/products/capella/) cluster with Data API enabled +- Couchbase [travel-sample](https://docs.couchbase.com/dotnet-sdk/current/ref/travel-app-data-model.html) bucket loaded ## Setup @@ -43,17 +43,41 @@ cd cloudflare-workers ```bash npm install ``` -4. Configure your environment variables in `wrangler.jsonc` or through Cloudflare dashboard: -```json -{ - "vars": { - "DATA_API_USERNAME": "your_username", - "DATA_API_PASSWORD": "your_password", - "DATA_API_ENDPOINT": "your_endpoint" - } -} +4. Configure your database (see Database Configuration section) +5. Configure your environment variables (see Deployment section for details) + +## Database Configuration + +Setup Database Configuration +To know more about connecting to your Capella cluster, please follow the [instructions](https://docs.couchbase.com/cloud/get-started/connect.html). + +Specifically, you need to do the following: + +1. Create [database credentials](https://docs.couchbase.com/cloud/clusters/manage-database-users.html) to access the travel-sample bucket (Read and Write) used in the application. +2. [Allow access](https://docs.couchbase.com/cloud/clusters/allow-ip-address.html) to the Cluster from the IP on which the application is running. + +## FTS Index Setup + +Before using the hotel search functionality, you need to create a Full Text Search index. A script is provided to create the required geo-spatial FTS index: + +### Using the FTS Index Creation Script + +1. Set your environment variables: +```bash +export DATA_API_USERNAME="your_username" +export DATA_API_PASSWORD="your_password" +export DATA_API_ENDPOINT="your_endpoint" +``` + +2. Run the script to create the FTS index: +```bash +./scripts/create-fts-index.sh ``` +This script creates a geo-spatial FTS index called `hotel-geo-index` that enables proximity searches on hotel documents. The index must be created before using the hotel search functionality. + +**Note:** The index creation is a one-time setup process. Once created, the index will be built in the background and will be ready for use with the hotel search endpoint. + ## Development Start the development server: @@ -61,9 +85,68 @@ Start the development server: npm run dev ``` +## Testing + +This project includes comprehensive unit tests for all handler functions using [Vitest](https://vitest.dev/) and the [@cloudflare/vitest-pool-workers](https://developers.cloudflare.com/workers/testing/vitest-integration/) testing framework. + +### Running Tests + +Run all tests: +```bash +npm run test +``` + +Run specific test categories: +```bash +# Run only handler tests +npm run test:handlers + +# Run a specific test file +npm test test/handlers/getHotelsNearAirport.spec.ts +``` + ## Deployment -Deploy to Cloudflare Workers: +### Prerequisites + +- [Cloudflare account](https://dash.cloudflare.com/) with verified email +- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/) installed + +### Authentication + +```bash +wrangler login +``` + +### Environment Variables + +Set your Couchbase Data API credentials using one of these methods: + +**Option 1: Cloudflare Dashboard (Recommended)** +1. Deploy first: `npm run deploy` +2. Go to [Workers Dashboard](https://dash.cloudflare.com/) → Your Worker → Settings → Environment Variables +3. Add: `DATA_API_USERNAME`, `DATA_API_PASSWORD`, `DATA_API_ENDPOINT` as secrets + +**Option 2: CLI Secrets** +```bash +wrangler secret put DATA_API_USERNAME +wrangler secret put DATA_API_PASSWORD +wrangler secret put DATA_API_ENDPOINT +``` + +**Option 3: wrangler.jsonc (Development only)** +```json +{ + "vars": { + "DATA_API_USERNAME": "your_username", + "DATA_API_PASSWORD": "your_password", + "DATA_API_ENDPOINT": "your_endpoint" + } +} +``` + +### Deploy + ```bash npm run deploy ``` @@ -96,40 +179,30 @@ curl -X DELETE https://your-worker.your-subdomain.workers.dev/airports/airport_1 ### Find routes for an airport ```bash -curl -X POST https://your-worker.your-subdomain.workers.dev/airports/routes \ - -H "Content-Type: application/json" \ - -d '{"airportCode": "LAX"}' +curl https://your-worker.your-subdomain.workers.dev/airports/LAX/routes ``` ### Find airlines for an airport ```bash -curl -X POST https://your-worker.your-subdomain.workers.dev/airports/airlines \ - -H "Content-Type: application/json" \ - -d '{"airportCode": "LAX"}' +curl https://your-worker.your-subdomain.workers.dev/airports/LAX/airlines ``` -### Create FTS index for hotel search +### Find hotels near an airport with specific distance ```bash -curl -X POST https://your-worker.your-subdomain.workers.dev/fts/index/create \ - -H "Content-Type: application/json" +curl "https://your-worker.your-subdomain.workers.dev/airports/airport_1254/hotels/nearby/50km" ``` -**Note:** This endpoint creates a geo-spatial FTS index called `hotel-geo-index` that enables proximity searches on hotel documents. The index must be created before using the hotel search functionality. - -### Find hotels near an airport -```bash -curl -X POST https://your-worker.your-subdomain.workers.dev/airports/hotels/nearby \ - -H "Content-Type: application/json" \ - -d '{"airportCode": "SFO", "distance": "10km"}' -``` +**Path Parameters:** +- `airport_id`: Airport document ID (required) - e.g., airport_1254, airport_1255 +- `distance`: Search radius (required) - e.g., 50km, 10km -**Parameters:** -- `airportCode`: FAA or ICAO code for the airport (required) -- `distance`: Search radius (optional, default: "5km") +**Prerequisites:** Make sure you have created the FTS index using the provided script before using this endpoint. ## Project Structure ``` +scripts/ # Utility scripts +└── create-fts-index.sh # Script to create FTS index for hotel search src/ ├── handlers/ # API route handlers │ ├── createAirport.ts @@ -138,7 +211,6 @@ src/ │ ├── deleteAirport.ts │ ├── getAirportRoutes.ts │ ├── getAirportAirlines.ts -│ ├── createFTSIndex.ts │ └── getHotelsNearAirport.ts ├── types/ # TypeScript type definitions │ ├── airport.ts @@ -146,6 +218,18 @@ src/ │ └── env.ts ├── utils/ # Utility functions └── index.ts # Main application entry point +test/ +├── handlers/ # Handler unit tests +│ ├── createAirport.spec.ts +│ ├── getAirport.spec.ts +│ ├── updateAirport.spec.ts +│ ├── deleteAirport.spec.ts +│ ├── getAirportRoutes.spec.ts +│ ├── getAirportAirlines.spec.ts +│ └── getHotelsNearAirport.spec.ts +├── utils/ # Test utilities and helpers +│ └── testHelpers.ts +└── setup.ts # Test setup configuration ``` ## License diff --git a/cloudflare-workers/package.json b/cloudflare-workers/package.json index 60d5a25..42d7bfe 100644 --- a/cloudflare-workers/package.json +++ b/cloudflare-workers/package.json @@ -7,6 +7,10 @@ "dev": "wrangler dev", "start": "wrangler dev", "test": "vitest", + "test:unit": "vitest run --reporter=verbose", + "test:watch": "vitest --watch", + "test:coverage": "vitest run --coverage", + "test:handlers": "vitest run test/handlers/", "cf-typegen": "wrangler types" }, "devDependencies": { diff --git a/cloudflare-workers/scripts/create-fts-index.sh b/cloudflare-workers/scripts/create-fts-index.sh new file mode 100755 index 0000000..87678f9 --- /dev/null +++ b/cloudflare-workers/scripts/create-fts-index.sh @@ -0,0 +1,136 @@ +#!/bin/bash + +# Script to create FTS index for hotel geo-spatial search +# This creates a Full Text Search index called 'hotel-geo-index' that enables proximity searches on hotel documents. +# +# Prerequisites: +# - Couchbase Server with Data API enabled +# - travel-sample bucket loaded with hotel documents in inventory.hotel collection +# - Hotels must have geo coordinates (geo.lat and geo.lon fields) for proximity search +# +# Usage: +# 1. Set your environment variables: +# export DATA_API_USERNAME="your_username" +# export DATA_API_PASSWORD="your_password" +# export DATA_API_ENDPOINT="your_endpoint" +# 2. Run this script: ./scripts/create-fts-index.sh + +# Check if environment variables are set +if [ -z "$DATA_API_USERNAME" ] || [ -z "$DATA_API_PASSWORD" ] || [ -z "$DATA_API_ENDPOINT" ]; then + echo "Error: Please set the following environment variables:" + echo " DATA_API_USERNAME" + echo " DATA_API_PASSWORD" + echo " DATA_API_ENDPOINT" + exit 1 +fi + +# Index configuration +INDEX_NAME="hotel-geo-index" +BUCKET_NAME="travel-sample" +SCOPE_NAME="inventory" + +# Construct the URL +URL="https://${DATA_API_ENDPOINT}/_p/fts/api/bucket/${BUCKET_NAME}/scope/${SCOPE_NAME}/index/${INDEX_NAME}" + +echo "Creating FTS index '${INDEX_NAME}' for geo-spatial hotel search..." +echo "URL: ${URL}" + +# Create the FTS index with geo-spatial mapping +curl -X PUT "${URL}" \ + -u "${DATA_API_USERNAME}:${DATA_API_PASSWORD}" \ + -H "Content-Type: application/json" \ + -d '{ + "type": "fulltext-index", + "name": "hotel-geo-index", + "sourceType": "gocbcore", + "sourceName": "travel-sample", + "sourceParams": {}, + "planParams": { + "maxPartitionsPerPIndex": 1024, + "indexPartitions": 1 + }, + "params": { + "doc_config": { + "docid_prefix_delim": "", + "docid_regexp": "", + "mode": "scope.collection.type_field", + "type_field": "type" + }, + "mapping": { + "analysis": {}, + "default_analyzer": "standard", + "default_datetime_parser": "dateTimeOptional", + "default_field": "_all", + "default_mapping": { + "dynamic": true, + "enabled": false + }, + "default_type": "_default", + "docvalues_dynamic": false, + "index_dynamic": true, + "store_dynamic": false, + "type_field": "_type", + "types": { + "inventory.hotel": { + "dynamic": true, + "enabled": true, + "properties": { + "geo": { + "dynamic": false, + "enabled": true, + "fields": [ + { + "analyzer": "keyword", + "include_in_all": true, + "include_term_vectors": true, + "index": true, + "name": "geo", + "store": true, + "type": "geopoint" + } + ] + }, + "name": { + "dynamic": false, + "enabled": true, + "fields": [ + { + "analyzer": "standard", + "include_in_all": true, + "include_term_vectors": true, + "index": true, + "name": "name", + "store": true, + "type": "text" + } + ] + }, + "city": { + "dynamic": false, + "enabled": true, + "fields": [ + { + "analyzer": "standard", + "include_in_all": true, + "include_term_vectors": true, + "index": true, + "name": "city", + "store": true, + "type": "text" + } + ] + } + } + } + } + }, + "store": { + "indexType": "scorch", + "segmentVersion": 15 + } + } + }' + +echo "" +echo "FTS index creation completed!" +echo "Note: The index is being built in the background. Use the hotel search endpoint once the index is ready." \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/createAirport.ts b/cloudflare-workers/src/handlers/createAirport.ts index f63833f..273206d 100644 --- a/cloudflare-workers/src/handlers/createAirport.ts +++ b/cloudflare-workers/src/handlers/createAirport.ts @@ -6,45 +6,43 @@ import { getAuthHeaders, getDocumentUrl } from '../utils/couchbase'; export const createAirport = async (c: Context<{ Bindings: Env }>) => { try { const documentKey = c.req.param('documentKey'); + const env = c.env; let airportData: AirportDocument; try { airportData = await c.req.json(); } catch (e) { - return new Response( - JSON.stringify({ error: 'Invalid JSON in request body for new airport' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: 'Invalid JSON in request body for new airport' }, + 400 ); } - const url = getDocumentUrl(c.env, documentKey); + const url = getDocumentUrl(env, documentKey); console.log(`Making POST request to: ${url}`); const response = await fetch(url, { method: 'POST', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env), body: JSON.stringify(airportData), }); if (!response.ok && response.status !== 201) { const errorBody = await response.text(); console.error(`POST API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error creating airport document: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error creating airport document: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - const responseData = await response.json().catch(() => ({})); - return new Response( - JSON.stringify(responseData), - { status: 201, headers: { 'Content-Type': 'application/json' } } - ); + const responseData = await response.json().catch(() => ({})) as any; + return c.json(responseData, 201); } catch (error: any) { console.error("Error handling POST request:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/createFTSIndex.ts b/cloudflare-workers/src/handlers/createFTSIndex.ts deleted file mode 100644 index 24c6c36..0000000 --- a/cloudflare-workers/src/handlers/createFTSIndex.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { Context } from 'hono'; -import { Env } from '../types/env'; -import { getAuthHeaders, getFTSIndexUrl } from '../utils/couchbase'; - -export const createFTSIndex = async (c: Context<{ Bindings: Env }>) => { - try { - const indexName = 'hotel-geo-index'; - const url = getFTSIndexUrl(c.env, indexName); - - // FTS index definition with geo-spatial mapping for scoped collections - const indexDefinition = { - "type": "fulltext-index", - "name": indexName, - "sourceType": "gocbcore", - "sourceName": "travel-sample", - "sourceParams": {}, - "planParams": { - "maxPartitionsPerPIndex": 1024, - "indexPartitions": 1 - }, - "params": { - "doc_config": { - "docid_prefix_delim": "", - "docid_regexp": "", - "mode": "scope.collection.type_field", - "type_field": "type" - }, - "mapping": { - "analysis": {}, - "default_analyzer": "standard", - "default_datetime_parser": "dateTimeOptional", - "default_field": "_all", - "default_mapping": { - "dynamic": true, - "enabled": false - }, - "default_type": "_default", - "docvalues_dynamic": false, - "index_dynamic": true, - "store_dynamic": false, - "type_field": "_type", - "types": { - "inventory.hotel": { - "dynamic": true, - "enabled": true, - "properties": { - "geo": { - "dynamic": false, - "enabled": true, - "fields": [ - { - "analyzer": "keyword", - "include_in_all": true, - "include_term_vectors": true, - "index": true, - "name": "geo", - "store": true, - "type": "geopoint" - } - ] - }, - "name": { - "dynamic": false, - "enabled": true, - "fields": [ - { - "analyzer": "standard", - "include_in_all": true, - "include_term_vectors": true, - "index": true, - "name": "name", - "store": true, - "type": "text" - } - ] - }, - "city": { - "dynamic": false, - "enabled": true, - "fields": [ - { - "analyzer": "standard", - "include_in_all": true, - "include_term_vectors": true, - "index": true, - "name": "city", - "store": true, - "type": "text" - } - ] - } - } - } - } - }, - "store": { - "indexType": "scorch", - "segmentVersion": 15 - } - } - }; - - console.log(`Creating FTS index at: ${url}`); - console.log(`Index definition:`, JSON.stringify(indexDefinition, null, 2)); - - const response = await fetch(url, { - method: 'PUT', - headers: getAuthHeaders(c.env), - body: JSON.stringify(indexDefinition) - }); - - if (!response.ok) { - const errorBody = await response.text(); - console.error(`FTS Index Creation Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ - error: `Error creating FTS index: ${response.statusText}`, - detail: errorBody - }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } - ); - } - - const result = await response.json(); - - return new Response( - JSON.stringify({ - message: `FTS index '${indexName}' created successfully`, - index_name: indexName, - status: "created", - details: result, - next_step: "The index is being built. Use POST /airports/hotels/nearby to search for hotels once ready." - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); - - } catch (error: any) { - console.error("Error creating FTS index:", error); - return new Response( - JSON.stringify({ - error: error.message - }), - { status: 500, headers: { 'Content-Type': 'application/json' } } - ); - } -}; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/deleteAirport.ts b/cloudflare-workers/src/handlers/deleteAirport.ts index 987cffb..20c1db8 100644 --- a/cloudflare-workers/src/handlers/deleteAirport.ts +++ b/cloudflare-workers/src/handlers/deleteAirport.ts @@ -5,36 +5,30 @@ import { getAuthHeaders, getDocumentUrl } from '../utils/couchbase'; export const deleteAirport = async (c: Context<{ Bindings: Env }>) => { try { const documentKey = c.req.param('documentKey'); - const url = getDocumentUrl(c.env, documentKey); + const env = c.env; + const url = getDocumentUrl(env, documentKey); console.log(`Making DELETE request to: ${url}`); - const response = await fetch(url, { + const response = await fetch(url, { method: 'DELETE', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env) }); - if (!response.ok && response.status !== 204) { + if (!response.ok) { const errorBody = await response.text(); console.error(`DELETE API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error deleting airport document: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error deleting airport document: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - if (response.status === 204) { - return new Response(null, { status: 204 }); - } - - return new Response( - JSON.stringify({ message: `Airport document ${documentKey} deleted successfully.` }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + return c.json({ message: `Airport document ${documentKey} deleted successfully.` }); } catch (error: any) { console.error("Error handling DELETE request:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/getAirport.ts b/cloudflare-workers/src/handlers/getAirport.ts index 314a8ee..ea72285 100644 --- a/cloudflare-workers/src/handlers/getAirport.ts +++ b/cloudflare-workers/src/handlers/getAirport.ts @@ -5,33 +5,31 @@ import { getAuthHeaders, getDocumentUrl } from '../utils/couchbase'; export const getAirport = async (c: Context<{ Bindings: Env }>) => { try { const documentKey = c.req.param('documentKey'); - const url = getDocumentUrl(c.env, documentKey); + const env = c.env; + const url = getDocumentUrl(env, documentKey); console.log(`Making GET request to: ${url}`); const response = await fetch(url, { method: 'GET', - headers: getAuthHeaders(c.env) + headers: getAuthHeaders(env) }); if (!response.ok) { const errorBody = await response.text(); console.error(`GET API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error fetching airport data: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error fetching airport data: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - const data = await response.json(); - return new Response( - JSON.stringify(data), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + const data = await response.json() as any; + return c.json(data); } catch (error: any) { console.error("Error handling GET request:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/getAirportAirlines.ts b/cloudflare-workers/src/handlers/getAirportAirlines.ts index 9db3765..5b10388 100644 --- a/cloudflare-workers/src/handlers/getAirportAirlines.ts +++ b/cloudflare-workers/src/handlers/getAirportAirlines.ts @@ -4,21 +4,16 @@ import { getAuthHeaders, getQueryUrl } from '../utils/couchbase'; export const getAirportAirlines = async (c: Context<{ Bindings: Env }>) => { try { - let requestBody: { airportCode: string }; + const airportCode = c.req.param('airportCode'); + const env = c.env; - try { - requestBody = await c.req.json(); - } catch (e) { - return new Response( - JSON.stringify({ error: 'Invalid JSON in request body' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ); - } - - if (!requestBody.airportCode) { - return new Response( - JSON.stringify({ error: 'Missing required field: airportCode' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } + if (!airportCode) { + return c.json( + { + error: 'Missing required path parameter: airportCode', + example: '/airports/LAX/airlines' + }, + 400 ); } @@ -29,43 +24,40 @@ export const getAirportAirlines = async (c: Context<{ Bindings: Env }>) => { ORDER BY r.airline `; - const args = [requestBody.airportCode, requestBody.airportCode]; + const args = [airportCode, airportCode]; const queryBody = { statement, args }; - const url = getQueryUrl(c.env); + const url = getQueryUrl(env); console.log(`Making query request to: ${url}`); console.log(`Query: ${statement}`); console.log(`Args: ${JSON.stringify(args)}`); const response = await fetch(url, { method: 'POST', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env), body: JSON.stringify(queryBody) }); if (!response.ok) { const errorBody = await response.text(); console.error(`Query API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error executing airlines query: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error executing airlines query: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - const data = await response.json(); - return new Response( - JSON.stringify(data), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + const data = await response.json() as any; + return c.json(data); } catch (error: any) { console.error("Error executing airlines query:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/getAirportRoutes.ts b/cloudflare-workers/src/handlers/getAirportRoutes.ts index d026099..28a170b 100644 --- a/cloudflare-workers/src/handlers/getAirportRoutes.ts +++ b/cloudflare-workers/src/handlers/getAirportRoutes.ts @@ -4,21 +4,16 @@ import { getAuthHeaders, getQueryUrl } from '../utils/couchbase'; export const getAirportRoutes = async (c: Context<{ Bindings: Env }>) => { try { - let requestBody: { airportCode: string }; - - try { - requestBody = await c.req.json(); - } catch (e) { - return new Response( - JSON.stringify({ error: 'Invalid JSON in request body' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } - ); - } + const airportCode = c.req.param('airportCode'); + const env = c.env; - if (!requestBody.airportCode) { - return new Response( - JSON.stringify({ error: 'Missing required field: airportCode' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } + if (!airportCode) { + return c.json( + { + error: 'Missing required path parameter: airportCode', + example: '/airports/LAX/routes' + }, + 400 ); } @@ -30,43 +25,40 @@ export const getAirportRoutes = async (c: Context<{ Bindings: Env }>) => { LIMIT 10 `; - const args = [requestBody.airportCode, requestBody.airportCode]; + const args = [airportCode, airportCode]; const queryBody = { statement, args }; - const url = getQueryUrl(c.env); + const url = getQueryUrl(env); console.log(`Making query request to: ${url}`); console.log(`Query: ${statement}`); console.log(`Args: ${JSON.stringify(args)}`); const response = await fetch(url, { method: 'POST', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env), body: JSON.stringify(queryBody) }); if (!response.ok) { const errorBody = await response.text(); console.error(`Query API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error executing routes query: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error executing routes query: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - const data = await response.json(); - return new Response( - JSON.stringify(data), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + const data = await response.json() as any; + return c.json(data); } catch (error: any) { console.error("Error executing routes query:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/getHotelsNearAirport.ts b/cloudflare-workers/src/handlers/getHotelsNearAirport.ts index b2c45cb..0ad7012 100644 --- a/cloudflare-workers/src/handlers/getHotelsNearAirport.ts +++ b/cloudflare-workers/src/handlers/getHotelsNearAirport.ts @@ -2,7 +2,7 @@ import { Context } from 'hono'; import { Env } from '../types/env'; import { AirportDocument } from '../types/airport'; import { HotelDocument } from '../types/hotel'; -import { getAuthHeaders, getQueryUrl, getFTSSearchUrl } from '../utils/couchbase'; +import { getAuthHeaders, getDocumentUrl, getFTSSearchUrl } from '../utils/couchbase'; // Type for FTS response interface FTSResponse { @@ -15,89 +15,64 @@ interface FTSResponse { total_hits?: number; } -// Type for N1QL query response -interface N1QLResponse { - results?: T[]; - status: string; - metrics?: any; -} - export const getHotelsNearAirport = async (c: Context<{ Bindings: Env }>) => { try { - const body = await c.req.json(); - const { airportCode, distance = "5km" } = body; + const airportId = c.req.param('airportId'); + const distance = c.req.param('distance'); + const env = c.env; // Validate input - if (!airportCode || typeof airportCode !== 'string') { - return new Response( - JSON.stringify({ - error: 'Invalid input. Airport code is required.', - example: { - airportCode: "SFO", - distance: "5km" - } - }), - { status: 400, headers: { 'Content-Type': 'application/json' } } + if (!airportId || typeof airportId !== 'string') { + return c.json( + { + error: 'Missing required path parameters: airportId and distance are both mandatory', + example: '/airports/airport_1254/hotels/nearby/50km' + }, + 400 ); } - // Step 1: Find airport by airport code using N1QL query - const airportQuery = ` - SELECT a.* - FROM \`travel-sample\`.inventory.airport a - WHERE a.faa = ? OR a.icao = ? - LIMIT 1 - `; - - const airportArgs = [airportCode.toUpperCase(), airportCode.toUpperCase()]; - - const airportQueryBody = { - statement: airportQuery, - args: airportArgs - }; + // Step 1: Get airport document by ID + const documentUrl = getDocumentUrl(env, airportId); - const queryUrl = getQueryUrl(c.env); - console.log(`Fetching airport data using N1QL query: ${airportQuery}`); - console.log(`Args: ${JSON.stringify(airportArgs)}`); + console.log(`Fetching airport data using GET: ${documentUrl}`); - const airportResponse = await fetch(queryUrl, { - method: 'POST', - headers: getAuthHeaders(c.env), - body: JSON.stringify(airportQueryBody) + const airportResponse = await fetch(documentUrl, { + method: 'GET', + headers: getAuthHeaders(env) }); if (!airportResponse.ok) { const errorBody = await airportResponse.text(); - console.error(`Airport Query API Error (${airportResponse.status}): ${errorBody}`); - return new Response( - JSON.stringify({ - error: `Error searching for airport: ${airportCode}`, + console.error(`Airport GET API Error (${airportResponse.status}): ${errorBody}`); + + if (airportResponse.status === 404) { + return c.json( + { + error: `Airport not found: ${airportId}`, + detail: "No airport document found with the specified document ID" + }, + 404 + ); + } + + return c.json( + { + error: `Error fetching airport: ${airportId}`, detail: errorBody - }), - { status: airportResponse.status, headers: { 'Content-Type': 'application/json' } } - ); - } - - const airportQueryResult = await airportResponse.json() as N1QLResponse; - - if (!airportQueryResult.results || airportQueryResult.results.length === 0) { - return new Response( - JSON.stringify({ - error: `Airport not found: ${airportCode}`, - detail: "No airport found with the specified FAA or ICAO code" - }), - { status: 404, headers: { 'Content-Type': 'application/json' } } + }, + airportResponse.status as any ); } - const airportData = airportQueryResult.results[0] as AirportDocument; + const airportData = await airportResponse.json() as AirportDocument; const { lat: latitude, lon: longitude } = airportData.geo; - console.log(`Airport ${airportCode} coordinates: lat=${latitude}, lon=${longitude}`); + console.log(`Airport ${airportId} coordinates: lat=${latitude}, lon=${longitude}`); // Step 2: Search for nearby hotels using FTS const indexName = 'hotel-geo-index'; - const ftsUrl = getFTSSearchUrl(c.env, indexName); + const ftsUrl = getFTSSearchUrl(env, indexName); // Construct FTS geo-distance query using standard format const ftsQuery = { @@ -131,20 +106,20 @@ export const getHotelsNearAirport = async (c: Context<{ Bindings: Env }>) => { const ftsResponse = await fetch(ftsUrl, { method: 'POST', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env), body: JSON.stringify(ftsQuery) }); if (!ftsResponse.ok) { const errorBody = await ftsResponse.text(); console.error(`FTS API Error (${ftsResponse.status}): ${errorBody}`); - return new Response( - JSON.stringify({ + return c.json( + { error: `Error searching for nearby hotels: ${ftsResponse.statusText}`, detail: errorBody, note: "Make sure the 'hotel-geo-index' FTS index exists with geo mapping for the 'geo' field" - }), - { status: ftsResponse.status, headers: { 'Content-Type': 'application/json' } } + }, + ftsResponse.status as any ); } @@ -160,35 +135,33 @@ export const getHotelsNearAirport = async (c: Context<{ Bindings: Env }>) => { }; }) || []; - return new Response( - JSON.stringify({ - airport: { - code: airportCode.toUpperCase(), - name: airportData.airportname, - city: airportData.city, - country: airportData.country, - coordinates: { - latitude, - longitude - } - }, - search_criteria: { - distance - }, - total_hotels_found: ftsData.total_hits || 0, - hotels: hotels - }), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + return c.json({ + airport: { + id: airportId, + code: airportData.faa || airportData.icao, + name: airportData.airportname, + city: airportData.city, + country: airportData.country, + coordinates: { + latitude, + longitude + } + }, + search_criteria: { + distance + }, + total_hotels_found: ftsData.total_hits || 0, + hotels: hotels + }); } catch (error: any) { console.error("Error in airport hotel search:", error); - return new Response( - JSON.stringify({ + return c.json( + { error: error.message, note: "This endpoint requires a Full Text Search index with geo-spatial mapping" - }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/handlers/updateAirport.ts b/cloudflare-workers/src/handlers/updateAirport.ts index 2f5c2ce..e1c7bcc 100644 --- a/cloudflare-workers/src/handlers/updateAirport.ts +++ b/cloudflare-workers/src/handlers/updateAirport.ts @@ -6,45 +6,43 @@ import { getAuthHeaders, getDocumentUrl } from '../utils/couchbase'; export const updateAirport = async (c: Context<{ Bindings: Env }>) => { try { const documentKey = c.req.param('documentKey'); + const env = c.env; let airportData: AirportDocument; try { airportData = await c.req.json(); } catch (e) { - return new Response( - JSON.stringify({ error: 'Invalid JSON in request body for airport update' }), - { status: 400, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: 'Invalid JSON in request body for airport update' }, + 400 ); } - const url = getDocumentUrl(c.env, documentKey); + const url = getDocumentUrl(env, documentKey); console.log(`Making PUT request to: ${url}`); const response = await fetch(url, { method: 'PUT', - headers: getAuthHeaders(c.env), + headers: getAuthHeaders(env), body: JSON.stringify(airportData), }); if (!response.ok) { const errorBody = await response.text(); console.error(`PUT API Error (${response.status}): ${errorBody}`); - return new Response( - JSON.stringify({ error: `Error updating airport document: ${response.statusText}. Detail: ${errorBody}` }), - { status: response.status, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: `Error updating airport document: ${response.statusText}. Detail: ${errorBody}` }, + response.status as any ); } - const responseData = await response.json().catch(() => (response.status === 204 ? {} : { message: 'Updated' })); - return new Response( - JSON.stringify(responseData), - { status: 200, headers: { 'Content-Type': 'application/json' } } - ); + const responseData = await response.json().catch(() => ({})) as any; + return c.json(responseData); } catch (error: any) { console.error("Error handling PUT request:", error); - return new Response( - JSON.stringify({ error: error.message }), - { status: 500, headers: { 'Content-Type': 'application/json' } } + return c.json( + { error: error.message }, + 500 ); } }; \ No newline at end of file diff --git a/cloudflare-workers/src/index.ts b/cloudflare-workers/src/index.ts index 742d6cf..5f7666c 100644 --- a/cloudflare-workers/src/index.ts +++ b/cloudflare-workers/src/index.ts @@ -7,9 +7,8 @@ import { deleteAirport } from './handlers/deleteAirport'; import { getAirportRoutes } from './handlers/getAirportRoutes'; import { getAirportAirlines } from './handlers/getAirportAirlines'; import { getHotelsNearAirport } from './handlers/getHotelsNearAirport'; -import { createFTSIndex } from './handlers/createFTSIndex'; -// Create Hono app +// Create Hono app with proper environment bindings const app = new Hono<{ Bindings: Env }>(); // Root route @@ -19,18 +18,14 @@ app.get('/', (c) => { }, 200); }); -// FTS Index Management -// Create FTS index for hotel geo-spatial search -app.post('/fts/index/create', createFTSIndex); - // Find hotels near a specific airport (FTS) -app.post('/airports/hotels/nearby', getHotelsNearAirport); +app.get('/airports/:airportId/hotels/nearby/:distance', getHotelsNearAirport); // Find routes for a specific airport -app.post('/airports/routes', getAirportRoutes); +app.get('/airports/:airportCode/routes', getAirportRoutes); // Find airlines that service a specific airport -app.post('/airports/airlines', getAirportAirlines); +app.get('/airports/:airportCode/airlines', getAirportAirlines); // Get airport app.get('/airports/:documentKey', getAirport); diff --git a/cloudflare-workers/test/handlers/createAirport.spec.ts b/cloudflare-workers/test/handlers/createAirport.spec.ts new file mode 100644 index 0000000..18ddc5e --- /dev/null +++ b/cloudflare-workers/test/handlers/createAirport.spec.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createAirport } from '../../src/handlers/createAirport'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, + mockAirportData, +} from '../utils/testHelpers'; + +describe('createAirport handler', () => { + it('should parse request body and make correct API call', async () => { + // Arrange + const documentKey = 'airport_new'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockResponse({}, 201, 'Created'); + mockFetch(mockResponse); + + // Act + const response = await createAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + body: JSON.stringify(mockAirportData), + }) + ); + expectSuccessResponse(response, 201); + }); + + it('should handle custom airport data in request body', async () => { + // Arrange + const documentKey = 'custom_airport'; + const customAirportData = { + ...mockAirportData, + airportname: 'Custom Airport', + faa: 'CUS', + id: 7777, + }; + const context = createMockContext({ documentKey }, {}, customAirportData); + const mockResponse = createMockResponse(customAirportData, 201); + mockFetch(mockResponse); + + // Act + await createAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify(customAirportData), + }) + ); + }); + + it('should return 400 when request body has invalid JSON', async () => { + // Arrange + const documentKey = 'airport_new'; + const context = createMockContext({ documentKey }); + context.req.json = vi.fn().mockRejectedValue(new Error('Invalid JSON')); + + // Act + const response = await createAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Invalid JSON in request body for new airport'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should return 400 when request body has malformed JSON syntax', async () => { + // Arrange + const documentKey = 'airport_new'; + const context = createMockContext({ documentKey }); + context.req.json = vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')); + + // Act + const response = await createAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Invalid JSON in request body for new airport'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should forward API response status and error message', async () => { + // Arrange + const documentKey = 'test_airport'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockErrorResponse(409, 'Conflict', 'Document already exists'); + mockFetch(mockResponse); + + // Act + const response = await createAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 409); + expect(responseData.error).toContain('Error creating airport document'); + expect(responseData.error).toContain('Conflict'); + expect(responseData.error).toContain('Document already exists'); + }); + + it('should handle API response without JSON body', async () => { + // Arrange + const documentKey = 'airport_new'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = new Response('', { + status: 201, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch(mockResponse); + + // Act + const response = await createAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 201); + expect(responseData).toEqual({}); + }); + + it('should return 500 when fetch fails', async () => { + // Arrange + const documentKey = 'airport_new'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network connection failed')); + + // Act + const response = await createAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network connection failed'); + }); + + it('should construct URL with document key from request params', async () => { + // Arrange + const documentKey = 'airport@test-123_special'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockResponse({}, 201); + mockFetch(mockResponse); + + // Act + await createAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.any(Object) + ); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/deleteAirport.spec.ts b/cloudflare-workers/test/handlers/deleteAirport.spec.ts new file mode 100644 index 0000000..4c56960 --- /dev/null +++ b/cloudflare-workers/test/handlers/deleteAirport.spec.ts @@ -0,0 +1,118 @@ +import { describe, it, expect, vi } from 'vitest'; +import { deleteAirport } from '../../src/handlers/deleteAirport'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectErrorResponse, +} from '../utils/testHelpers'; + +describe('deleteAirport handler', () => { + it('should make correct DELETE request', async () => { + // Arrange + const documentKey = 'airport_delete'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockResponse({ message: `Airport document ${documentKey} deleted successfully.` }, 200); + mockFetch(mockResponse); + + // Act + const response = await deleteAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.objectContaining({ + method: 'DELETE', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + }) + ); + expect(response.status).toBe(200); + }); + + it('should handle 204 No Content response', async () => { + // Arrange + const documentKey = 'airport_delete'; + const context = createMockContext({ documentKey }); + const mockResponse = new Response('', { status: 204 }); + mockFetch(mockResponse); + + // Act + const response = await deleteAirport(context); + const responseData = await parseResponse(response); + + // Assert + expect(response.status).toBe(200); + expect(responseData.message).toBe(`Airport document ${documentKey} deleted successfully.`); + }); + + it('should return success message for 200 response', async () => { + // Arrange + const documentKey = 'airport_delete'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockResponse({}, 200); + mockFetch(mockResponse); + + // Act + const response = await deleteAirport(context); + const responseData = await parseResponse(response); + + // Assert + expect(response.status).toBe(200); + expect(responseData.message).toBe(`Airport document ${documentKey} deleted successfully.`); + }); + + it('should forward API response status and error message', async () => { + // Arrange + const documentKey = 'nonexistent_airport'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockErrorResponse(404, 'Not Found', 'Document not found'); + mockFetch(mockResponse); + + // Act + const response = await deleteAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 404); + expect(responseData.error).toContain('Error deleting airport document'); + expect(responseData.error).toContain('Not Found'); + expect(responseData.error).toContain('Document not found'); + }); + + it('should return 500 when fetch fails', async () => { + // Arrange + const documentKey = 'airport_delete'; + const context = createMockContext({ documentKey }); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network connection failed')); + + // Act + const response = await deleteAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network connection failed'); + }); + + it('should construct URL with document key from request params', async () => { + // Arrange + const documentKey = 'airport@delete-123'; + const context = createMockContext({ documentKey }); + const mockResponse = new Response('', { status: 204 }); + mockFetch(mockResponse); + + // Act + await deleteAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.any(Object) + ); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/getAirport.spec.ts b/cloudflare-workers/test/handlers/getAirport.spec.ts new file mode 100644 index 0000000..30fba70 --- /dev/null +++ b/cloudflare-workers/test/handlers/getAirport.spec.ts @@ -0,0 +1,179 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getAirport } from '../../src/handlers/getAirport'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, + mockAirportData, +} from '../utils/testHelpers'; + +describe('getAirport handler', () => { + describe('Success Cases', () => { + it('should return airport data when document exists', async () => { + // Arrange + const documentKey = 'airport_1254'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockResponse(mockAirportData); + mockFetch(mockResponse); + + // Act + const response = await getAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toEqual(mockAirportData); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + }) + ); + }); + + it('should construct correct URL with document key', async () => { + // Arrange + const documentKey = 'test_airport_123'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockResponse(mockAirportData); + mockFetch(mockResponse); + + // Act + await getAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + 'https://test.endpoint.com/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/test_airport_123', + expect.any(Object) + ); + }); + }); + + describe('Error Cases', () => { + it('should handle 404 when document does not exist', async () => { + // Arrange + const documentKey = 'nonexistent_airport'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockErrorResponse(404, 'Not Found', 'Document not found'); + mockFetch(mockResponse); + + // Act + const response = await getAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 404); + expect(responseData.error).toContain('Error fetching airport data'); + expect(responseData.error).toContain('Not Found'); + }); + + it('should handle 401 unauthorized error', async () => { + // Arrange + const documentKey = 'airport_1254'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockErrorResponse(401, 'Unauthorized', 'Invalid credentials'); + mockFetch(mockResponse); + + // Act + const response = await getAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 401); + expect(responseData.error).toContain('Error fetching airport data'); + expect(responseData.error).toContain('Unauthorized'); + }); + + it('should handle 500 server error', async () => { + // Arrange + const documentKey = 'airport_1254'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockErrorResponse(500, 'Internal Server Error'); + mockFetch(mockResponse); + + // Act + const response = await getAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toContain('Error fetching airport data'); + }); + + it('should handle fetch network errors', async () => { + // Arrange + const documentKey = 'airport_1254'; + const context = createMockContext({ documentKey }); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network error')); + + // Act + const response = await getAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network error'); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty document key', async () => { + // Arrange + const context = createMockContext({ documentKey: '' }); + const mockResponse = createMockResponse(mockAirportData); + mockFetch(mockResponse); + + // Act + await getAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/documents/'), + expect.any(Object) + ); + }); + + it('should handle special characters in document key', async () => { + // Arrange + const documentKey = 'airport@test-123_special'; + const context = createMockContext({ documentKey }); + const mockResponse = createMockResponse(mockAirportData); + mockFetch(mockResponse); + + // Act + await getAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.any(Object) + ); + }); + + it('should handle invalid JSON response from API', async () => { + // Arrange + const documentKey = 'airport_1254'; + const context = createMockContext({ documentKey }); + const mockResponse = new Response('invalid json', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch(mockResponse); + + // Act + const response = await getAirport(context); + + // Assert + // Should handle gracefully - the response.json() will throw but should be caught + expect(response.status).toBe(500); + }); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/getAirportAirlines.spec.ts b/cloudflare-workers/test/handlers/getAirportAirlines.spec.ts new file mode 100644 index 0000000..febb93c --- /dev/null +++ b/cloudflare-workers/test/handlers/getAirportAirlines.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getAirportAirlines } from '../../src/handlers/getAirportAirlines'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, +} from '../utils/testHelpers'; + +describe('getAirportAirlines handler', () => { + it('should make correct query request with airport code', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockQueryResult = { + results: [ + { airline: 'American Airlines' }, + { airline: 'United Airlines' } + ] + }; + const mockResponse = createMockResponse(mockQueryResult, 200); + mockFetch(mockResponse); + + // Act + const response = await getAirportAirlines(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/_p/query/query/service'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + body: expect.stringContaining('travel-sample'), + }) + ); + expectSuccessResponse(response, 200); + }); + + it('should construct correct SQL query with airport code parameters', async () => { + // Arrange + const airportCode = 'JFK'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse({}, 200); + mockFetch(mockResponse); + + // Act + await getAirportAirlines(context); + + // Assert + const callArgs = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1].body); + + expect(requestBody.statement).toContain('SELECT DISTINCT r.airline'); + expect(requestBody.statement).toContain('FROM `travel-sample`.inventory.route r'); + expect(requestBody.statement).toContain('WHERE r.sourceairport = ? OR r.destinationairport = ?'); + expect(requestBody.statement).toContain('ORDER BY r.airline'); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + + it('should return 400 when airportCode path parameter is missing', async () => { + // Arrange + const context = createMockContext({}); // No path parameters + + // Act + const response = await getAirportAirlines(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Missing required path parameter: airportCode'); + expect(responseData.example).toBe('/airports/LAX/airlines'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should handle different airport codes', async () => { + // Arrange + const airportCode = 'SFO'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse({ results: [] }, 200); + mockFetch(mockResponse); + + // Act + await getAirportAirlines(context); + + // Assert + const callArgs = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1].body); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + + it('should forward API response data', async () => { + // Arrange + const airportCode = 'DEN'; + const context = createMockContext({ airportCode }); + const mockQueryResult = { + results: [ + { airline: 'Southwest Airlines' }, + { airline: 'Frontier Airlines' } + ], + metrics: { resultCount: 2 } + }; + const mockResponse = createMockResponse(mockQueryResult, 200); + mockFetch(mockResponse); + + // Act + const response = await getAirportAirlines(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toEqual(mockQueryResult); + }); + + it('should forward API error response', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockErrorResponse(400, 'Bad Request', 'Invalid query syntax'); + mockFetch(mockResponse); + + // Act + const response = await getAirportAirlines(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toContain('Error executing airlines query'); + expect(responseData.error).toContain('Bad Request'); + expect(responseData.error).toContain('Invalid query syntax'); + }); + + it('should return 500 when fetch fails', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network connection failed')); + + // Act + const response = await getAirportAirlines(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network connection failed'); + }); + + it('should handle special characters in airport code', async () => { + // Arrange + const airportCode = 'A@B-123'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse({}, 200); + mockFetch(mockResponse); + + // Act + await getAirportAirlines(context); + + // Assert + const callArgs = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(callArgs[1].body); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/getAirportRoutes.spec.ts b/cloudflare-workers/test/handlers/getAirportRoutes.spec.ts new file mode 100644 index 0000000..69d067c --- /dev/null +++ b/cloudflare-workers/test/handlers/getAirportRoutes.spec.ts @@ -0,0 +1,287 @@ +import { describe, it, expect, vi } from 'vitest'; +import { getAirportRoutes } from '../../src/handlers/getAirportRoutes'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, +} from '../utils/testHelpers'; + +describe('getAirportRoutes handler', () => { + const mockRoutesData = { + results: [ + { + sourceairport: 'LAX', + destinationairport: 'SFO', + airline: 'UA', + equipment: '737', + schedule: [{ day: 1, flight: 'UA1234' }], + }, + { + sourceairport: 'SFO', + destinationairport: 'LAX', + airline: 'AA', + equipment: '757', + schedule: [{ day: 2, flight: 'AA5678' }], + }, + ], + status: 'success', + metrics: { resultCount: 2, executionTime: '12.34ms' }, + }; + + describe('Success Cases', () => { + it('should return routes for valid airport code', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toEqual(mockRoutesData); + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining('/_p/query/query/service'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + body: expect.stringContaining(airportCode), + }) + ); + }); + + it('should construct correct SQL query with airport code', async () => { + // Arrange + const airportCode = 'SFO'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + await getAirportRoutes(context); + + // Assert + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + + expect(requestBody.statement).toContain('FROM `travel-sample`.inventory.route r'); + expect(requestBody.statement).toContain('WHERE r.sourceairport = ? OR r.destinationairport = ?'); + expect(requestBody.statement).toContain('ORDER BY r.sourceairport, r.destinationairport'); + expect(requestBody.statement).toContain('LIMIT 10'); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + + it('should handle case-sensitive airport codes', async () => { + // Arrange + const airportCode = 'lax'; // lowercase + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + await getAirportRoutes(context); + + // Assert + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.args).toEqual(['lax', 'lax']); + }); + + it('should handle empty results from query', async () => { + // Arrange + const airportCode = 'XYZ'; + const context = createMockContext({ airportCode }); + const emptyResults = { results: [], status: 'success', metrics: { resultCount: 0 } }; + const mockResponse = createMockResponse(emptyResults); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData.results).toEqual([]); + expect(responseData.status).toBe('success'); + }); + }); + + describe('Validation Errors', () => { + it('should return 400 when airportCode parameter is missing', async () => { + // Arrange + const context = createMockContext({}); // No path parameters + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Missing required path parameter: airportCode'); + expect(responseData.example).toBe('/airports/LAX/routes'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should return 400 when airportCode parameter is empty string', async () => { + // Arrange + const context = createMockContext({ airportCode: '' }); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Missing required path parameter: airportCode'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + + }); + + describe('Query API Error Cases', () => { + it('should handle 401 unauthorized error', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockErrorResponse(401, 'Unauthorized', 'Invalid credentials'); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 401); + expect(responseData.error).toContain('Error executing routes query'); + expect(responseData.error).toContain('Unauthorized'); + }); + + it('should handle 500 database error', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockErrorResponse(500, 'Internal Server Error', 'Database connection failed'); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toContain('Error executing routes query'); + expect(responseData.error).toContain('Internal Server Error'); + }); + + it('should handle syntax error in SQL query', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockErrorResponse(400, 'Bad Request', 'SQL syntax error'); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toContain('Error executing routes query'); + expect(responseData.error).toContain('Bad Request'); + }); + + it('should handle fetch network errors', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network timeout')); + + // Act + const response = await getAirportRoutes(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network timeout'); + }); + }); + + describe('Edge Cases', () => { + it('should handle special characters in airport code', async () => { + // Arrange + const airportCode = 'LAX-1'; // Airport code with special character + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + await getAirportRoutes(context); + + // Assert + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + + it('should handle very long airport code', async () => { + // Arrange + const airportCode = 'VERYLONGAIRPORTCODE123'; + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + await getAirportRoutes(context); + + // Assert + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + + it('should handle malformed JSON response from query API', async () => { + // Arrange + const airportCode = 'LAX'; + const context = createMockContext({ airportCode }); + const mockResponse = new Response('invalid json response', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch(mockResponse); + + // Act + const response = await getAirportRoutes(context); + + // Assert + // The response.json() call will fail and should be handled gracefully + expect(response.status).toBe(500); + }); + + it('should handle query with unicode characters in airport code', async () => { + // Arrange + const airportCode = 'LAX™'; // Airport code with unicode + const context = createMockContext({ airportCode }); + const mockResponse = createMockResponse(mockRoutesData); + mockFetch(mockResponse); + + // Act + await getAirportRoutes(context); + + // Assert + const fetchCall = (globalThis.fetch as any).mock.calls[0]; + const requestBody = JSON.parse(fetchCall[1].body); + expect(requestBody.args).toEqual([airportCode, airportCode]); + }); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/getHotelsNearAirport.spec.ts b/cloudflare-workers/test/handlers/getHotelsNearAirport.spec.ts new file mode 100644 index 0000000..26ff45d --- /dev/null +++ b/cloudflare-workers/test/handlers/getHotelsNearAirport.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getHotelsNearAirport } from '../../src/handlers/getHotelsNearAirport'; +import { + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, +} from '../utils/testHelpers'; + +const mockAirportData = { + id: 1254, + faa: 'LAX', + airportname: 'Los Angeles Intl', + city: 'Los Angeles', + country: 'United States', + geo: { + lat: 33.942536, + lon: -118.408075 + } +}; + +const mockHotelSearchResponse = { + hits: [ + { + fields: { + id: 'hotel_123', + name: 'Airport Hotel', + city: 'Los Angeles', + geo: { lat: 33.943, lon: -118.409 } + }, + score: 0.95 + } + ], + total_hits: 1 +}; + +describe('getHotelsNearAirport handler', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should validate airportId path parameter', async () => { + // Arrange + const context = createMockContext(); // No path parameters + + // Act + const response = await getHotelsNearAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Missing required path parameters: airportId and distance are both mandatory'); + expect(responseData.example).toBe('/airports/airport_1254/hotels/nearby/50km'); + }); + + it('should validate empty airportId path parameter', async () => { + // Arrange + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return ''; + return undefined; + }); + + // Act + const response = await getHotelsNearAirport(context); + const responseData = await parseResponse(response); + + // Assert + expect(response.status).toBe(400); + expect(responseData.error).toBe('Missing required path parameters: airportId and distance are both mandatory'); + }); + + it('should use distance parameter when provided', async () => { + // Arrange + const airportId = 'airport_1254'; + const distance = '10km'; + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return airportId; + if (key === 'distance') return distance; + return undefined; + }); + + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(createMockResponse(mockAirportData, 200)) + .mockResolvedValueOnce(createMockResponse(mockHotelSearchResponse, 200)); + + // Act + await getHotelsNearAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + const secondCall = (globalThis.fetch as any).mock.calls[1]; + const ftsRequestBody = JSON.parse(secondCall[1].body); + expect(ftsRequestBody.query.distance).toBe(distance); + }); + + it('should return 404 when airport not found', async () => { + // Arrange + const airportId = 'nonexistent_airport'; + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return airportId; + return undefined; + }); + + globalThis.fetch = vi.fn().mockResolvedValueOnce( + createMockErrorResponse(404, 'Not Found', 'Document not found') + ); + + // Act + const response = await getHotelsNearAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 404); + expect(responseData.error).toBe(`Airport not found: ${airportId}`); + expect(responseData.detail).toBe('No airport document found with the specified document ID'); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); // Only airport call, no hotel search + }); + + it('should make API call with correct airport endpoint', async () => { + // Arrange + const airportId = 'airport_1254'; + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return airportId; + return undefined; + }); + + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(createMockResponse(mockAirportData, 200)) + .mockResolvedValueOnce(createMockResponse(mockHotelSearchResponse, 200)); + + // Act + await getHotelsNearAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + + // First call: Get airport document + expect(globalThis.fetch).toHaveBeenNthCalledWith(1, + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${airportId}`), + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + }), + }) + ); + }); + + it('should format response with airport and hotel data', async () => { + // Arrange + const airportId = 'airport_1254'; + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return airportId; + return undefined; + }); + + globalThis.fetch = vi.fn() + .mockResolvedValueOnce(createMockResponse(mockAirportData, 200)) + .mockResolvedValueOnce(createMockResponse(mockHotelSearchResponse, 200)); + + // Act + const response = await getHotelsNearAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toHaveProperty('airport'); + expect(responseData.airport.id).toBe(airportId); + expect(responseData.airport.code).toBe(mockAirportData.faa); + expect(responseData.airport.name).toBe(mockAirportData.airportname); + expect(responseData.airport.coordinates.latitude).toBe(mockAirportData.geo.lat); + expect(responseData.airport.coordinates.longitude).toBe(mockAirportData.geo.lon); + + expect(responseData).toHaveProperty('search_criteria'); + expect(responseData).toHaveProperty('total_hotels_found', 1); + expect(responseData).toHaveProperty('hotels'); + expect(responseData.hotels).toHaveLength(1); + }); + + it('should handle network errors gracefully', async () => { + // Arrange + const airportId = 'airport_1254'; + const context = createMockContext(); + context.req.param.mockImplementation((key: string) => { + if (key === 'airportId') return airportId; + return undefined; + }); + + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network connection failed')); + + // Act + const response = await getHotelsNearAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network connection failed'); + expect(responseData.note).toContain('Full Text Search index'); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/handlers/updateAirport.spec.ts b/cloudflare-workers/test/handlers/updateAirport.spec.ts new file mode 100644 index 0000000..38f7dfa --- /dev/null +++ b/cloudflare-workers/test/handlers/updateAirport.spec.ts @@ -0,0 +1,168 @@ +import { describe, it, expect, vi } from 'vitest'; +import { updateAirport } from '../../src/handlers/updateAirport'; +import { + mockFetch, + createMockContext, + createMockResponse, + createMockErrorResponse, + parseResponse, + expectSuccessResponse, + expectErrorResponse, + mockAirportData, +} from '../utils/testHelpers'; + +describe('updateAirport handler', () => { + it('should parse request body and make correct PUT request', async () => { + // Arrange + const documentKey = 'airport_update'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockResponse({ message: 'Updated' }, 200); + mockFetch(mockResponse); + + // Act + const response = await updateAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Authorization': expect.stringContaining('Basic'), + 'Content-Type': 'application/json', + }), + body: JSON.stringify(mockAirportData), + }) + ); + expectSuccessResponse(response, 200); + }); + + it('should handle custom airport data in request body', async () => { + // Arrange + const documentKey = 'custom_airport'; + const customAirportData = { + ...mockAirportData, + airportname: 'Updated Airport', + faa: 'UPD', + id: 9999, + }; + const context = createMockContext({ documentKey }, {}, customAirportData); + const mockResponse = createMockResponse({}, 200); + mockFetch(mockResponse); + + // Act + await updateAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + body: JSON.stringify(customAirportData), + }) + ); + }); + + it('should return 400 when request body has invalid JSON', async () => { + // Arrange + const documentKey = 'airport_update'; + const context = createMockContext({ documentKey }); + context.req.json = vi.fn().mockRejectedValue(new Error('Invalid JSON')); + + // Act + const response = await updateAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 400); + expect(responseData.error).toBe('Invalid JSON in request body for airport update'); + expect(globalThis.fetch).not.toHaveBeenCalled(); + }); + + it('should forward API response status and error message', async () => { + // Arrange + const documentKey = 'test_airport'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockErrorResponse(404, 'Not Found', 'Document not found'); + mockFetch(mockResponse); + + // Act + const response = await updateAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 404); + expect(responseData.error).toContain('Error updating airport document'); + expect(responseData.error).toContain('Not Found'); + expect(responseData.error).toContain('Document not found'); + }); + + it('should handle API response without JSON body', async () => { + // Arrange + const documentKey = 'airport_update'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = new Response('', { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch(mockResponse); + + // Act + const response = await updateAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toEqual({}); + }); + + it('should handle 204 No Content response', async () => { + // Arrange + const documentKey = 'airport_update'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = new Response('', { + status: 204, + headers: { 'Content-Type': 'application/json' }, + }); + mockFetch(mockResponse); + + // Act + const response = await updateAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectSuccessResponse(response, 200); + expect(responseData).toEqual({}); + }); + + it('should return 500 when fetch fails', async () => { + // Arrange + const documentKey = 'airport_update'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + globalThis.fetch = vi.fn().mockRejectedValue(new Error('Network connection failed')); + + // Act + const response = await updateAirport(context); + const responseData = await parseResponse(response); + + // Assert + expectErrorResponse(response, 500); + expect(responseData.error).toBe('Network connection failed'); + }); + + it('should construct URL with document key from request params', async () => { + // Arrange + const documentKey = 'airport@update-123'; + const context = createMockContext({ documentKey }, {}, mockAirportData); + const mockResponse = createMockResponse({}, 200); + mockFetch(mockResponse); + + // Act + await updateAirport(context); + + // Assert + expect(globalThis.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/v1/buckets/travel-sample/scopes/inventory/collections/airport/documents/${documentKey}`), + expect.any(Object) + ); + }); +}); \ No newline at end of file diff --git a/cloudflare-workers/test/index.spec.ts b/cloudflare-workers/test/index.spec.ts deleted file mode 100644 index fbee335..0000000 --- a/cloudflare-workers/test/index.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -// test/index.spec.ts -import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test'; -import { describe, it, expect } from 'vitest'; -import worker from '../src/index'; - -// For now, you'll need to do something like this to get a correctly-typed -// `Request` to pass to `worker.fetch()`. -const IncomingRequest = Request; - -describe('Hello World worker', () => { - it('responds with Hello World! (unit style)', async () => { - const request = new IncomingRequest('http://example.com'); - // Create an empty context to pass to `worker.fetch()`. - const ctx = createExecutionContext(); - const response = await worker.fetch(request, env, ctx); - // Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions - await waitOnExecutionContext(ctx); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); - }); - - it('responds with Hello World! (integration style)', async () => { - const response = await SELF.fetch('https://example.com'); - expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`); - }); -}); diff --git a/cloudflare-workers/test/setup.ts b/cloudflare-workers/test/setup.ts new file mode 100644 index 0000000..331472b --- /dev/null +++ b/cloudflare-workers/test/setup.ts @@ -0,0 +1,16 @@ +import { beforeEach, afterEach, vi } from 'vitest'; + +// Global test setup +beforeEach(() => { + // Clear all mocks before each test + vi.clearAllMocks(); +}); + +afterEach(() => { + // Restore console methods + vi.restoreAllMocks(); +}); + +// Global fetch mock - used by all tests +// Individual tests can override with: globalThis.fetch = vi.fn().mockResolvedValue(...) +globalThis.fetch = vi.fn(); \ No newline at end of file diff --git a/cloudflare-workers/test/utils/testHelpers.ts b/cloudflare-workers/test/utils/testHelpers.ts new file mode 100644 index 0000000..740de21 --- /dev/null +++ b/cloudflare-workers/test/utils/testHelpers.ts @@ -0,0 +1,104 @@ +import { vi, expect } from 'vitest'; +import { Env } from '../../src/types/env'; +import { AirportDocument } from '../../src/types/airport'; + +// Test environment +export const mockEnv: Env = { + DATA_API_USERNAME: 'test_user', + DATA_API_PASSWORD: 'test_password', + DATA_API_ENDPOINT: 'test.endpoint.com', +}; + +// Sample test data +export const mockAirportData: AirportDocument = { + airportname: 'Test Airport', + city: 'Test City', + country: 'Test Country', + faa: 'TST', + geo: { + alt: 100, + lat: 34.0522, + lon: -118.2437, + }, + icao: 'KTST', + id: 9999, + type: 'airport', + tz: 'America/Los_Angeles', +}; + +// Mock fetch responses +export const createMockResponse = ( + data: any, + status: number = 200, + statusText: string = 'OK' +) => { + return new Response(JSON.stringify(data), { + status, + statusText, + headers: { 'Content-Type': 'application/json' }, + }); +}; + +export const createMockErrorResponse = ( + status: number, + statusText: string, + errorMessage?: string +) => { + return new Response(errorMessage || statusText, { + status, + statusText, + }); +}; + +// Global fetch mock helper +export const mockFetch = (mockResponse: Response) => { + globalThis.fetch = vi.fn().mockResolvedValue(mockResponse); + return globalThis.fetch as any; +}; + +// Mock Hono Context +export const createMockContext = ( + params: Record = {}, + query: Record = {}, + body?: any +) => { + const mockRequest = { + param: vi.fn((key: string) => params[key]), + query: vi.fn((key: string) => query[key]), + json: vi.fn().mockResolvedValue(body), + }; + + const mockJson = vi.fn((data: any, status?: number) => { + return new Response(JSON.stringify(data), { + status: status || 200, + headers: { 'Content-Type': 'application/json' }, + }); + }); + + return { + req: mockRequest, + env: mockEnv, + json: mockJson, + } as any; +}; + +// Helper to parse response +export const parseResponse = async (response: Response) => { + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + return text; + } +}; + +// Assertion helpers +export const expectSuccessResponse = (response: Response, expectedStatus: number = 200) => { + expect(response.status).toBe(expectedStatus); + expect(response.headers.get('Content-Type')).toBe('application/json'); +}; + +export const expectErrorResponse = (response: Response, expectedStatus: number) => { + expect(response.status).toBe(expectedStatus); + expect(response.headers.get('Content-Type')).toBe('application/json'); +}; \ No newline at end of file diff --git a/cloudflare-workers/vitest.config.mts b/cloudflare-workers/vitest.config.mts index 977f64c..da0e600 100644 --- a/cloudflare-workers/vitest.config.mts +++ b/cloudflare-workers/vitest.config.mts @@ -2,6 +2,18 @@ import { defineWorkersConfig } from '@cloudflare/vitest-pool-workers/config'; export default defineWorkersConfig({ test: { + setupFiles: ['./test/setup.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + exclude: [ + 'node_modules/', + 'test/', + '.wrangler/', + 'dist/', + '*.config.*', + ], + }, poolOptions: { workers: { wrangler: { configPath: './wrangler.jsonc' },