diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 9f3912b8..fa0ca7ad 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -35,7 +35,7 @@ npm start # Start server (http://loc ```bash npm run runtest # Run full test suite (25+ minutes, requires MongoDB) npm run runtest -- __tests__/routes_mounted.test.js # Run route mounting tests (30 seconds, no DB needed) -npm run runtest -- routes/__tests__/create.test.js # Run specific test file +npm run runtest -- src/routes/__tests__/create.test.js # Run specific test file ``` **Important:** Use `npm run runtest` (not `npm test`) as it enables experimental VM modules required for ES6 imports in Jest. @@ -60,27 +60,27 @@ curl -X POST http://localhost:3001/v1/api/query -H "Content-Type: application/js The application follows a **layered architecture** with clear separation of concerns: ``` -app.js (Express setup, middleware) +src/index.js (Express app setup, middleware) ↓ -routes/api-routes.js (route mounting & definitions) +src/routes/api-routes.js (route mounting & definitions) ↓ -routes/*.js (individual route handlers with JWT auth) +src/routes/*.js (individual route handlers with JWT auth) ↓ -db-controller.js (controller aggregator) +src/routes/db-controller.js (controller aggregator) ↓ -controllers/*.js (business logic modules) +src/controllers/*.js (business logic modules) ↓ -database/index.js (MongoDB connection & operations) +src/db/index.js (MongoDB connection & operations) ``` ### Key Architectural Components **1. Request Flow:** - Client → Express middleware (CORS, logging, body parsing) -- → Auth middleware (JWT validation via Auth0) -- → Route handlers (routes/*.js) -- → Controllers (controllers/*.js with business logic) -- → Database operations (MongoDB via database/index.js) +- → Auth middleware (JWT validation via Auth0, src/auth/index.js) +- → Route handlers (src/routes/*.js) +- → Controllers (src/controllers/*.js with business logic) +- → Database operations (MongoDB via src/db/index.js) - → Response with proper Linked Data HTTP headers **2. Versioning System:** @@ -92,8 +92,8 @@ database/index.js (MongoDB connection & operations) - Released objects are immutable (isReleased !== "") **3. Controllers Organization:** -The `db-controller.js` is a facade that imports from specialized controller modules: -- `controllers/crud.js`: Core create, query, id operations +The `src/routes/db-controller.js` is a facade that imports from specialized controller modules in `src/controllers/`: +- `controllers/crud.js`: Core create, query, id operations (id() delegates to src/services/crudService.js) - `controllers/update.js`: PUT/PATCH update operations (putUpdate, patchUpdate, patchSet, patchUnset, overwrite) - `controllers/delete.js`: Delete operations - `controllers/history.js`: Version history and since queries, HEAD request handlers @@ -105,7 +105,7 @@ The `db-controller.js` is a facade that imports from specialized controller modu **4. Authentication & Authorization:** - **Provider:** Auth0 JWT bearer tokens -- **Middleware:** `auth/index.js` with express-oauth2-jwt-bearer +- **Middleware:** `src/auth/index.js` with express-oauth2-jwt-bearer - **Flow:** checkJwt array includes READONLY check, Auth0 validation, token error handling, user extraction - **Agent Matching:** Write operations verify `req.user` matches `__rerum.generatedBy` - **Bot Access:** Special bot tokens (BOT_TOKEN, BOT_AGENT) bypass some checks @@ -119,16 +119,16 @@ The `db-controller.js` is a facade that imports from specialized controller modu ### Directory Structure ``` -/bin/ Entry point (rerum_v1.js creates HTTP server) -/routes/ Route handlers (one file per endpoint typically) -/controllers/ Business logic organized by domain -/auth/ Authentication middleware and token handling -/database/ MongoDB connection and utilities +/bin/ Entry point (rerum_v1.js creates HTTP server, imports src/index.js) +/src/ + index.js Express app setup and middleware configuration + routes/ Route handlers (api-routes.js, home.js, id.js, create.js, etc.) + controllers/ Business logic organized by domain + auth/ Authentication middleware and token handling (Auth0 JWT) + db/ MongoDB connection and utilities + services/ Business/service layer (e.g. crudService.js for id-by-id logic) + utils/ Core utilities (__rerum configuration, header generation), rest.js /public/ Static files (API.html docs, context.json) -/utils.js Core utilities (__rerum configuration, header generation) -/rest.js REST error handling and messaging -/app.js Express app setup and middleware configuration -/db-controller.js Controller facade exporting all operations ``` ## Important Patterns and Conventions @@ -205,7 +205,7 @@ BOT_AGENT=your-bot-agent-url ## Testing Notes - **Route tests** (`__tests__/routes_mounted.test.js`): Work without MongoDB, verify routing and static files -- **Controller tests** (`routes/__tests__/*.test.js`): Require MongoDB connection or will timeout after 5 seconds +- **Controller tests** (`src/routes/__tests__/*.test.js`): Require MongoDB connection or will timeout after 5 seconds - Tests use experimental VM modules, hence `npm run runtest` instead of `npm test` - "Jest did not exit" warnings are normal—tests complete successfully despite this - Most tests expect Auth0 to be configured; mock tokens are used in test environment diff --git a/__tests__/routes_mounted.test.js b/__tests__/routes_mounted.test.js index edd53716..fb726548 100644 --- a/__tests__/routes_mounted.test.js +++ b/__tests__/routes_mounted.test.js @@ -6,8 +6,8 @@ */ import request from "supertest" -import api_routes from "../routes/api-routes.js" -import app from "../app.js" +import api_routes from "../src/routes/api-routes.js" +import app from "../src/index.js" import fs from "fs" let app_stack = app.router.stack diff --git a/bin/rerum_v1.js b/bin/rerum_v1.js index 8b269269..fa200c55 100644 --- a/bin/rerum_v1.js +++ b/bin/rerum_v1.js @@ -4,7 +4,7 @@ * Module dependencies. */ -import app from '../app.js' +import app from '../src/index.js' import debug from 'debug' debug('rerum_server_nodejs:server') import http from "http" diff --git a/jest.config.js b/jest.config.js index c5a4eb46..f089fd7b 100644 --- a/jest.config.js +++ b/jest.config.js @@ -32,7 +32,7 @@ const config = { collectCoverageFrom: [ //"**/*.js", "**/db-controller.js", - "**/routes/*.js" + "**/services/*.js" ], // Indicates which provider should be used to instrument code for coverage diff --git a/routes/index.js b/routes/index.js deleted file mode 100644 index f4b4a938..00000000 --- a/routes/index.js +++ /dev/null @@ -1,9 +0,0 @@ -import express from 'express' -const router = express.Router() - -/* GET home page. */ -router.get('/', (req, res, next) => { - res.sendFile('index.html', { root: 'public' }) -}) - -export default router diff --git a/auth/__mocks__/index.txt b/src/auth/__mocks__/index.txt similarity index 100% rename from auth/__mocks__/index.txt rename to src/auth/__mocks__/index.txt diff --git a/auth/__tests__/token.test.txt b/src/auth/__tests__/token.test.txt similarity index 97% rename from auth/__tests__/token.test.txt rename to src/auth/__tests__/token.test.txt index bb09f524..899fc197 100644 --- a/auth/__tests__/token.test.txt +++ b/src/auth/__tests__/token.test.txt @@ -9,7 +9,7 @@ * If that is what you need, get out of here and go see /auth/__mocks__ */ -import auth from "../../auth/index.js" +import auth from "../index.js" import httpMocks from "node-mocks-http" const goodToken = "TODO -- MAKE ME PROGRAMMATICALLY" diff --git a/auth/index.js b/src/auth/index.js similarity index 100% rename from auth/index.js rename to src/auth/index.js diff --git a/src/config/__mocks__/index.txt b/src/config/__mocks__/index.txt new file mode 100644 index 00000000..3809a6a5 --- /dev/null +++ b/src/config/__mocks__/index.txt @@ -0,0 +1,9 @@ +It would be extremely useful to mock Auth0 JWT stuff. +We will need to do some manual mocking to make these functions return the expected responses. +Remember we are using ES6 now, so ignore documentation for other syntaxes. +See https://jestjs.io/docs/es6-class-mocks#manual-mock +See https://jestjs.io/docs/es6-class-mocks#manual-mock-that-is-another-es6-class + +Remember that responses are Auth0 Responses and would need to be shaped as such. +See https://zhifei-dev.medium.com/express-typescript-properly-mocking-jwt-verify-in-unit-test-b2dfd2e33 +See https://www.npmjs.com/package/oauth2-mock-server \ No newline at end of file diff --git a/src/config/__tests__/token.test.txt b/src/config/__tests__/token.test.txt new file mode 100644 index 00000000..899fc197 --- /dev/null +++ b/src/config/__tests__/token.test.txt @@ -0,0 +1,56 @@ +/** + * Use this to perform end to end interactions with Auth0 TPEN3 Application. + * The app passes NodeJS Express Request and Response objects which have Bearer Tokens in their headers. + * Those Bearer tokens are pulled from the Request 'Authorization' header. + * The app should be able to verify the token is legitimate and gleam a TPEN3 user from it + * + * Note that in this test we are performing real Auth0 communication. + * There are areas of the app that could benefit from having this communication exist as a mock. + * If that is what you need, get out of here and go see /auth/__mocks__ +*/ + +import auth from "../index.js" +import httpMocks from "node-mocks-http" + +const goodToken = "TODO -- MAKE ME PROGRAMMATICALLY" + +// A mocked HTTP POST 'create' request with an Authorization header. The token should be a valid one. +const mockRequest_with_token = httpMocks.createRequest({ + method: 'POST', + url: '/create', + body: { + hello: 'world' + }, + headers: { + "Authorization" : `Bearer ${goodToken}` + } +}) + +// A mocked HTTP POST 'create' request without an Authorization header (no Bearer token) +const mockRequest_without_token = httpMocks.createRequest({ + method: 'POST', + url: '/create', + body: { + hello: 'world' + } +}) + +// A mocked HTTP response stub +const mockResponse = httpMocks.createResponse() + +// A mocked express next() call +const nextFunction = jest.fn() + +// REDO +describe('Auth0 Interactions',()=>{ + + it('reject empty request without headers (INCOMPLETE)',async ()=>{ + const resp = await auth.checkJwt[0](mockRequest_without_token,mockResponse,nextFunction) + expect(resp).toBe("token error") + }) + + it('with "authorization" header (INCOMPLETE)', async () => { + const resp = await auth.checkJwt[0](mockRequest_with_token,mockResponse,nextFunction) + expect(resp).toBe("valid token") + }) +}) diff --git a/src/config/index.js b/src/config/index.js new file mode 100644 index 00000000..695fe9b4 --- /dev/null +++ b/src/config/index.js @@ -0,0 +1,193 @@ +import { auth } from 'express-oauth2-jwt-bearer' +import dotenv from 'dotenv' +dotenv.config() + +const _tokenError = function (err, req, res, next) { + if(!err.code || err.code !== "invalid_token"){ + next(err) + return + } + try{ + let user = JSON.parse(Buffer.from(req.header("authorization").split(" ")[1].split('.')[1], 'base64').toString()) + if(isBot(user)){ + console.log("Request allowed via bot check") + next() + return + } + } + catch(e){ + e.message = e.statusMessage = `This token did not contain a known RERUM agent.` + e.status = 401 + e.statusCode = 401 + next(e) + } + next(err) +} + +const _extractUser = (req, res, next) => { + try{ + req.user = JSON.parse(Buffer.from(req.header("authorization").split(" ")[1].split('.')[1], 'base64').toString()) + next() + } + catch(e){ + e.message = e.statusMessage = `This token did not contain a known RERUM agent.}` + e.status = 401 + e.statusCode = 401 + next(e) + } +} + +/** + * Use like: + * app.get('/api/private', checkJwt, function(req, res) { + * // do authorized things + * }); + */ +const checkJwt = [READONLY, auth(), _tokenError, _extractUser] + +/** + * Public API proxy to generate new access tokens through Auth0 + * with a refresh token when original access has expired. + * @param {ExpressRequest} req from registered server application. + * @param {ExpressResponse} res to return the new token. + */ +const generateNewAccessToken = async (req, res, next) => { + console.log("RERUM v1 is generating a proxy access token.") + const form = { + grant_type: 'refresh_token', + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + refresh_token: req.body.refresh_token, + redirect_uri:process.env.RERUM_PREFIX + } + try{ + // Successful responses from auth 0 look like {"refresh_token":"BLAHBLAH", "access_token":"BLAHBLAH"} + // Error responses come back as successful, but they look like {"error":"blahblah", "error_description": "this is why"} + const tokenObj = await fetch('https://cubap.auth0.com/oauth/token', + { + method: 'POST', + headers: { + "Content-Type": "application/json" + }, + body:JSON.stringify(form) + }) + .then(resp => resp.json()) + .catch(err => { + // Mock Auth0 error object + console.error(err) + return {"error": true, "error_description":err} + }) + // Here we need to check if this is an Auth0 success object or an Auth0 error object + if(tokenObj.error){ + console.error(tokenObj.error_description) + res.status(500).send(tokenObj.error_description) + } + else{ + res.status(200).send(tokenObj) + } + } + catch (e) { + console.error(e.response ? e.response.body : e.message ? e.message : e) + res.status(500).send(e) + } +} + +/** + * Used by RERUM to renew the refresh token upon user request. + * @param {ExpressRequest} req from registered server application. + * @param {ExpressResponse} res to return the new token. + */ +const generateNewRefreshToken = async (req, res, next) => { + console.log("RERUM v1 is generating a new refresh token.") + const form = { + grant_type: 'authorization_code', + client_id: process.env.CLIENT_ID, + client_secret: process.env.CLIENT_SECRET, + code: req.body.authorization_code, + redirect_uri:process.env.RERUM_PREFIX + } + try { + // Successful responses from auth 0 look like {"refresh_token":"BLAHBLAH", "access_token":"BLAHBLAH"} + // Error responses come back as successful, but they look like {"error":"blahblah", "error_description": "this is why"} + const tokenObj = await fetch('https://cubap.auth0.com/oauth/token', + { + method: 'POST', + headers: { + "Content-Type": "application/json" + }, + body:JSON.stringify(form) + }) + .then(resp => resp.json()) + .catch(err => { + // Mock Auth0 error object + console.error(err) + return {"error": true, "error_description":err} + }) + // Here we need to check if this is an Auth0 success object or an Auth0 error object + if(tokenObj.error){ + console.error(tokenObj.error_description) + res.status(500).send(tokenObj.error_description) + } + else{ + res.status(200).send(tokenObj) + } + } + catch (e) { + console.error(e.response ? e.response.body : e.message ? e.message : e) + res.status(500).send(e) + } +} + +/** + * Upon requesting an action, confirm the request has a valid token. + * @param {(Base64)String} secret access_token from `Bearer` header in request + * @returns decoded payload of JWT if successful + * @throws Error if token, signature, or date is invalid + */ +const verifyAccess = (secret) => { + return jwt({ + secret, + audience: `http://rerum.io/api`, + issuer: `https://rerum.io/`, + algorithms: ['RS256'] + }) +} + +/** + * + * @param {Object} obj RERUM database entry + * @param {Object} User object discerned from token + * @returns Boolean match between encoded Generator Agent and obj generator + */ +const isGenerator = (obj, userObj) => { + return userObj[process.env.RERUM_AGENT_CLAIM] === obj.__rerum.generatedBy +} + +/** + * Even expired tokens may be accepted if the Agent is a known bot. This is a + * dangerous thing to include, but may be a useful convenience. + * @param {Object} User object discerned from token + * @returns Boolean for matching ID. + */ +const isBot = (userObj) => { + return process.env.BOT_AGENT === userObj[process.env.RERUM_AGENT_CLAIM] +} + +function READONLY(req, res, next) { + if(process.env.READONLY=="true"){ + res.status(503).json({"message":"RERUM v1 is read only at this time. We apologize for the inconvenience. Try again later."}) + return + } + next() + return +} + +export default { + checkJwt, + generateNewAccessToken, + generateNewRefreshToken, + verifyAccess, + isBot, + isGenerator, + READONLY +} diff --git a/controllers/bulk.js b/src/controllers/bulk.js similarity index 98% rename from controllers/bulk.js rename to src/controllers/bulk.js index 35e7fcb5..eee9c92e 100644 --- a/controllers/bulk.js +++ b/src/controllers/bulk.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** diff --git a/controllers/crud.js b/src/controllers/crud.js similarity index 72% rename from controllers/crud.js rename to src/controllers/crud.js index 7702de58..0f573d44 100644 --- a/controllers/crud.js +++ b/src/controllers/crud.js @@ -4,9 +4,10 @@ * Basic CRUD operations for RERUM v1 * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, idNegotiation, generateSlugId, ObjectID, createExpressError, getAgentClaim, parseDocumentID } from './utils.js' +import { getIdById, getLocationHeader } from '../services/crudService.js' /** * Create a new Linked Open Data object in RERUM v1. @@ -89,34 +90,43 @@ const query = async function (req, res, next) { } /** - * Query the MongoDB for objects with the _id provided in the request body or request URL - * Note this specifically checks for _id, the @id pattern is irrelevant. - * Note /v1/id/{blank} does not route here. It routes to the generic 404 - * */ + * Get an object by ID - HTTP Controller + * This controller handles only HTTP request/response concerns. + * All business logic and database operations are delegated to crudService. + * + * Note: /v1/id/{blank} does not route here. It routes to the generic 404 + */ const id = async function (req, res, next) { res.set("Content-Type", "application/json; charset=utf-8") - let id = req.params["_id"] + const idParam = req.params["_id"] + try { - let match = await db.findOne({"$or": [{"_id": id}, {"__rerum.slug": id}]}) - if (match) { - res.set(utils.configureWebAnnoHeadersFor(match)) - //Support built in browser caching - res.set("Cache-Control", "max-age=86400, must-revalidate") - //Support requests with 'If-Modified_Since' headers - res.set(utils.configureLastModifiedHeader(match)) - // Include current version for optimistic locking - const currentVersion = match.__rerum?.isOverwritten ?? "" - res.set('Current-Overwritten-Version', currentVersion) - match = idNegotiation(match) - res.location(_contextid(match["@context"]) ? match.id : match["@id"]) - res.json(match) + // Delegate business logic to service + const match = await getIdById(idParam) + + if (!match) { + const err = { + message: `No RERUM object with id '${idParam}'`, + status: 404 + } + next(createExpressError(err)) return } - let err = { - "message": `No RERUM object with id '${id}'`, - "status": 404 - } - next(createExpressError(err)) + + // HTTP response handling only + res.set(utils.configureWebAnnoHeadersFor(match)) + res.set("Cache-Control", "max-age=86400, must-revalidate") + res.set(utils.configureLastModifiedHeader(match)) + + // Include current version for optimistic locking + const currentVersion = match.__rerum?.isOverwritten ?? "" + res.set('Current-Overwritten-Version', currentVersion) + + // Apply id negotiation + const negotiatedMatch = idNegotiation(match) + res.location(getLocationHeader(negotiatedMatch)) + res.json(negotiatedMatch) + } catch (error) { next(createExpressError(error)) } diff --git a/controllers/delete.js b/src/controllers/delete.js similarity index 99% rename from controllers/delete.js rename to src/controllers/delete.js index 12aec2ac..4fb4d6a3 100644 --- a/controllers/delete.js +++ b/src/controllers/delete.js @@ -4,8 +4,8 @@ * Delete operations for RERUM v1 * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { createExpressError, getAgentClaim, parseDocumentID, getAllVersions, getAllDescendants } from './utils.js' /** diff --git a/controllers/gog.js b/src/controllers/gog.js similarity index 99% rename from controllers/gog.js rename to src/controllers/gog.js index 67dd04de..f350cf7a 100644 --- a/controllers/gog.js +++ b/src/controllers/gog.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** diff --git a/controllers/history.js b/src/controllers/history.js similarity index 98% rename from controllers/history.js rename to src/controllers/history.js index f0ad0031..972aedc9 100644 --- a/controllers/history.js +++ b/src/controllers/history.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, getAllVersions, getAllAncestors, getAllDescendants } from './utils.js' /** diff --git a/controllers/overwrite.js b/src/controllers/overwrite.js similarity index 98% rename from controllers/overwrite.js rename to src/controllers/overwrite.js index 284fac89..40378785 100644 --- a/controllers/overwrite.js +++ b/src/controllers/overwrite.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation } from './utils.js' /** diff --git a/controllers/patchSet.js b/src/controllers/patchSet.js similarity index 98% rename from controllers/patchSet.js rename to src/controllers/patchSet.js index 85e97af8..ccf9438f 100644 --- a/controllers/patchSet.js +++ b/src/controllers/patchSet.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** diff --git a/controllers/patchUnset.js b/src/controllers/patchUnset.js similarity index 98% rename from controllers/patchUnset.js rename to src/controllers/patchUnset.js index c4cf53d7..62759748 100644 --- a/controllers/patchUnset.js +++ b/src/controllers/patchUnset.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** diff --git a/controllers/patchUpdate.js b/src/controllers/patchUpdate.js similarity index 98% rename from controllers/patchUpdate.js rename to src/controllers/patchUpdate.js index c7271bbb..ade969ea 100644 --- a/controllers/patchUpdate.js +++ b/src/controllers/patchUpdate.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** diff --git a/controllers/putUpdate.js b/src/controllers/putUpdate.js similarity index 98% rename from controllers/putUpdate.js rename to src/controllers/putUpdate.js index 177507ac..1404ab0e 100644 --- a/controllers/putUpdate.js +++ b/src/controllers/putUpdate.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, alterHistoryNext } from './utils.js' /** diff --git a/controllers/release.js b/src/controllers/release.js similarity index 98% rename from controllers/release.js rename to src/controllers/release.js index 62f26f04..bbc17609 100644 --- a/controllers/release.js +++ b/src/controllers/release.js @@ -6,8 +6,8 @@ * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' import { _contextid, ObjectID, createExpressError, getAgentClaim, parseDocumentID, idNegotiation, generateSlugId, establishReleasesTree, healReleasesTree } from './utils.js' /** diff --git a/controllers/search.js b/src/controllers/search.js similarity index 99% rename from controllers/search.js rename to src/controllers/search.js index 5a688abf..013152bd 100644 --- a/controllers/search.js +++ b/src/controllers/search.js @@ -4,8 +4,8 @@ * Search ($search) operations for RERUM v1 * @author thehabes */ -import { db } from '../database/index.js' -import utils from '../utils.js' +import { db } from '../db/index.js' +import utils from '../utils/index.js' import { idNegotiation, createExpressError } from './utils.js' /** diff --git a/controllers/update.js b/src/controllers/update.js similarity index 100% rename from controllers/update.js rename to src/controllers/update.js diff --git a/controllers/utils.js b/src/controllers/utils.js similarity index 99% rename from controllers/utils.js rename to src/controllers/utils.js index 9da47cea..8dbafade 100644 --- a/controllers/utils.js +++ b/src/controllers/utils.js @@ -4,8 +4,8 @@ * Utility functions for RERUM controllers * @author Claude Sonnet 4, cubap, thehabes */ -import { newID, isValidID, db } from '../database/index.js' -import utils from '../utils.js' +import { newID, isValidID, db } from '../db/index.js' +import utils from '../utils/index.js' const ObjectID = newID diff --git a/database/__mocks__/index.txt b/src/db/__mocks__/index.txt similarity index 100% rename from database/__mocks__/index.txt rename to src/db/__mocks__/index.txt diff --git a/database/index.js b/src/db/index.js similarity index 100% rename from database/index.js rename to src/db/index.js diff --git a/app.js b/src/index.js similarity index 95% rename from app.js rename to src/index.js index fa6e7900..d1ef328e 100644 --- a/app.js +++ b/src/index.js @@ -7,12 +7,12 @@ import dotenv from 'dotenv' dotenv.config() import logger from 'morgan' import cors from 'cors' -import indexRouter from './routes/index.js' +import indexRouter from './routes/home.js' import apiRouter from './routes/api-routes.js' import clientRouter from './routes/client.js' -import _gog_fragmentsRouter from './routes/_gog_fragments_from_manuscript.js'; -import _gog_glossesRouter from './routes/_gog_glosses_from_manuscript.js'; -import rest from './rest.js' +import _gog_fragmentsRouter from './routes/_gog_fragments_from_manuscript.js' +import _gog_glossesRouter from './routes/_gog_glosses_from_manuscript.js' +import rest from './utils/rest.js' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -63,7 +63,7 @@ app.use(express.urlencoded({ extended: true })) app.use(cookieParser()) //Publicly available scripts, CSS, and HTML pages. -app.use(express.static(path.join(__dirname, 'public'))) +app.use(express.static(path.join(__dirname, '../public'))) /** * For any request that comes through to the app, check whether or not we are in maintenance mode. diff --git a/routes/__tests__/bulkCreate.test.js b/src/routes/__tests__/bulkCreate.test.js similarity index 96% rename from routes/__tests__/bulkCreate.test.js rename to src/routes/__tests__/bulkCreate.test.js index 917cc7e6..321573e1 100644 --- a/routes/__tests__/bulkCreate.test.js +++ b/src/routes/__tests__/bulkCreate.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user and the controller can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/bulkUpdate.test.js b/src/routes/__tests__/bulkUpdate.test.js similarity index 94% rename from routes/__tests__/bulkUpdate.test.js rename to src/routes/__tests__/bulkUpdate.test.js index e857e5a0..d6f155e9 100644 --- a/routes/__tests__/bulkUpdate.test.js +++ b/src/routes/__tests__/bulkUpdate.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user and the controller can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/client.test.txt b/src/routes/__tests__/client.test.txt similarity index 100% rename from routes/__tests__/client.test.txt rename to src/routes/__tests__/client.test.txt diff --git a/routes/__tests__/compatability.test.txt b/src/routes/__tests__/compatability.test.txt similarity index 100% rename from routes/__tests__/compatability.test.txt rename to src/routes/__tests__/compatability.test.txt diff --git a/routes/__tests__/create.test.js b/src/routes/__tests__/create.test.js similarity index 94% rename from routes/__tests__/create.test.js rename to src/routes/__tests__/create.test.js index 788247f9..c18d4a1a 100644 --- a/routes/__tests__/create.test.js +++ b/src/routes/__tests__/create.test.js @@ -1,8 +1,8 @@ import { jest } from "@jest/globals" import express from "express" import request from "supertest" -import { db } from '../../database/index.js' -import controller from '../../db-controller.js' +import { db } from '../../db/index.js' +import controller from '../db-controller.js' const rerum_uri = `${process.env.RERUM_ID_PREFIX}123456` diff --git a/routes/__tests__/crud_routes_function.txt b/src/routes/__tests__/crud_routes_function.txt similarity index 99% rename from routes/__tests__/crud_routes_function.txt rename to src/routes/__tests__/crud_routes_function.txt index 511c3caa..b21ec9d0 100644 --- a/routes/__tests__/crud_routes_function.txt +++ b/src/routes/__tests__/crud_routes_function.txt @@ -8,9 +8,9 @@ import request from 'supertest' //Fun fact, if you don't require app, you don't get coverage even though the tests run just fine. -import app from '../../app.js' +import app from '../../../src/index.js' //This is so we can do Mongo specific things with the objects in this test, like actually remove them from the db. -import controller from '../../db-controller.js' +import controller from '../db-controller.js' //A super fun note. If you do request(app), the tests will fail due to race conditions. //request = request(app) diff --git a/routes/__tests__/delete.test.js b/src/routes/__tests__/delete.test.js similarity index 96% rename from routes/__tests__/delete.test.js rename to src/routes/__tests__/delete.test.js index ac012840..45e5fd08 100644 --- a/routes/__tests__/delete.test.js +++ b/src/routes/__tests__/delete.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user and the controller can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/history.test.js b/src/routes/__tests__/history.test.js similarity index 95% rename from routes/__tests__/history.test.js rename to src/routes/__tests__/history.test.js index c4c87b22..94f5c5a7 100644 --- a/routes/__tests__/history.test.js +++ b/src/routes/__tests__/history.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' const routeTester = new express() routeTester.use(express.json()) diff --git a/routes/__tests__/id.test.js b/src/routes/__tests__/id.test.js similarity index 96% rename from routes/__tests__/id.test.js rename to src/routes/__tests__/id.test.js index 7300f21b..4e64d536 100644 --- a/routes/__tests__/id.test.js +++ b/src/routes/__tests__/id.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' const routeTester = new express() routeTester.use(express.json()) diff --git a/routes/__tests__/idNegotiation.test.js b/src/routes/__tests__/idNegotiation.test.js similarity index 95% rename from routes/__tests__/idNegotiation.test.js rename to src/routes/__tests__/idNegotiation.test.js index c9b5c33a..e8e8ef68 100644 --- a/routes/__tests__/idNegotiation.test.js +++ b/src/routes/__tests__/idNegotiation.test.js @@ -1,6 +1,6 @@ import { jest } from "@jest/globals" import dotenv from "dotenv" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' it("Functional '@id-id' negotiation on objects returned.", async () => { let negotiate = { diff --git a/routes/__tests__/overwrite-optimistic-locking.test.txt b/src/routes/__tests__/overwrite-optimistic-locking.test.txt similarity index 99% rename from routes/__tests__/overwrite-optimistic-locking.test.txt rename to src/routes/__tests__/overwrite-optimistic-locking.test.txt index 3ef6486e..44272771 100644 --- a/routes/__tests__/overwrite-optimistic-locking.test.txt +++ b/src/routes/__tests__/overwrite-optimistic-locking.test.txt @@ -15,7 +15,7 @@ jest.mock('../../database/index.js', () => ({ })) // Import controller after mocking -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Helper to add auth to requests const addAuth = (req, res, next) => { diff --git a/routes/__tests__/overwrite.test.txt b/src/routes/__tests__/overwrite.test.txt similarity index 99% rename from routes/__tests__/overwrite.test.txt rename to src/routes/__tests__/overwrite.test.txt index 129d7ea0..cd8c567f 100644 --- a/routes/__tests__/overwrite.test.txt +++ b/src/routes/__tests__/overwrite.test.txt @@ -1,5 +1,5 @@ import request from 'supertest' -import app from '../../app.js' +import app from '../../../src/index.js' import { jest } from '@jest/globals' // Mock the database and auth modules diff --git a/routes/__tests__/patch.test.js b/src/routes/__tests__/patch.test.js similarity index 97% rename from routes/__tests__/patch.test.js rename to src/routes/__tests__/patch.test.js index a4d9ebc1..f7f2775d 100644 --- a/routes/__tests__/patch.test.js +++ b/src/routes/__tests__/patch.test.js @@ -4,7 +4,7 @@ dotenv.config() // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user and the controller can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/query.test.js b/src/routes/__tests__/query.test.js similarity index 96% rename from routes/__tests__/query.test.js rename to src/routes/__tests__/query.test.js index b593b6f9..875dd950 100644 --- a/routes/__tests__/query.test.js +++ b/src/routes/__tests__/query.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' const routeTester = new express() routeTester.use(express.json()) diff --git a/routes/__tests__/release.test.js b/src/routes/__tests__/release.test.js similarity index 97% rename from routes/__tests__/release.test.js rename to src/routes/__tests__/release.test.js index eb6c6e3a..8d8c9b84 100644 --- a/routes/__tests__/release.test.js +++ b/src/routes/__tests__/release.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user so controller.create can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/set.test.js b/src/routes/__tests__/set.test.js similarity index 97% rename from routes/__tests__/set.test.js rename to src/routes/__tests__/set.test.js index 1559356c..c2382e19 100644 --- a/routes/__tests__/set.test.js +++ b/src/routes/__tests__/set.test.js @@ -5,7 +5,7 @@ dotenv.config() // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user and the controller can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/since.test.js b/src/routes/__tests__/since.test.js similarity index 95% rename from routes/__tests__/since.test.js rename to src/routes/__tests__/since.test.js index 13f9579f..2ba8c99e 100644 --- a/routes/__tests__/since.test.js +++ b/src/routes/__tests__/since.test.js @@ -3,7 +3,7 @@ import { jest } from "@jest/globals" // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' const routeTester = new express() routeTester.use(express.json()) diff --git a/routes/__tests__/unset.test.js b/src/routes/__tests__/unset.test.js similarity index 97% rename from routes/__tests__/unset.test.js rename to src/routes/__tests__/unset.test.js index e3c8c97c..1be5f6de 100644 --- a/routes/__tests__/unset.test.js +++ b/src/routes/__tests__/unset.test.js @@ -5,7 +5,7 @@ dotenv.config() // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user so controller.create can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/__tests__/update.test.js b/src/routes/__tests__/update.test.js similarity index 97% rename from routes/__tests__/update.test.js rename to src/routes/__tests__/update.test.js index df5e21a3..3d4dbeae 100644 --- a/routes/__tests__/update.test.js +++ b/src/routes/__tests__/update.test.js @@ -4,7 +4,7 @@ dotenv.config() // Only real way to test an express route is to mount it and call it so that we can use the req, res, next. import express from "express" import request from "supertest" -import controller from '../../db-controller.js' +import controller from '../db-controller.js' // Here is the auth mock so we get a req.user so controller.create can function without a NPE. const addAuth = (req, res, next) => { diff --git a/routes/_gog_fragments_from_manuscript.js b/src/routes/_gog_fragments_from_manuscript.js similarity index 90% rename from routes/_gog_fragments_from_manuscript.js rename to src/routes/_gog_fragments_from_manuscript.js index d1f30193..bba32f6e 100644 --- a/routes/_gog_fragments_from_manuscript.js +++ b/src/routes/_gog_fragments_from_manuscript.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/_gog_glosses_from_manuscript.js b/src/routes/_gog_glosses_from_manuscript.js similarity index 90% rename from routes/_gog_glosses_from_manuscript.js rename to src/routes/_gog_glosses_from_manuscript.js index e5c57659..af067cb5 100644 --- a/routes/_gog_glosses_from_manuscript.js +++ b/src/routes/_gog_glosses_from_manuscript.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/api-routes.js b/src/routes/api-routes.js similarity index 100% rename from routes/api-routes.js rename to src/routes/api-routes.js diff --git a/routes/bulkCreate.js b/src/routes/bulkCreate.js similarity index 90% rename from routes/bulkCreate.js rename to src/routes/bulkCreate.js index 8eb2fc90..b0c0bb81 100644 --- a/routes/bulkCreate.js +++ b/src/routes/bulkCreate.js @@ -3,7 +3,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/bulkUpdate.js b/src/routes/bulkUpdate.js similarity index 90% rename from routes/bulkUpdate.js rename to src/routes/bulkUpdate.js index f7fad3fa..c2fb95f7 100644 --- a/routes/bulkUpdate.js +++ b/src/routes/bulkUpdate.js @@ -3,7 +3,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/client.js b/src/routes/client.js similarity index 100% rename from routes/client.js rename to src/routes/client.js diff --git a/routes/compatability.js b/src/routes/compatability.js similarity index 100% rename from routes/compatability.js rename to src/routes/compatability.js diff --git a/routes/create.js b/src/routes/create.js similarity index 90% rename from routes/create.js rename to src/routes/create.js index 97b86975..e8760f18 100644 --- a/routes/create.js +++ b/src/routes/create.js @@ -2,7 +2,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/db-controller.js b/src/routes/db-controller.js similarity index 62% rename from db-controller.js rename to src/routes/db-controller.js index 07aa6f65..ce54724d 100644 --- a/db-controller.js +++ b/src/routes/db-controller.js @@ -7,15 +7,15 @@ */ // Import controller modules -import { index, idNegotiation, generateSlugId, remove } from './controllers/utils.js' -import { create, query, id } from './controllers/crud.js' -import { searchAsWords, searchAsPhrase } from './controllers/search.js' -import { deleteObj } from './controllers/delete.js' -import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from './controllers/update.js' -import { bulkCreate, bulkUpdate } from './controllers/bulk.js' -import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from './controllers/history.js' -import { release } from './controllers/release.js' -import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand } from './controllers/gog.js' +import { index, idNegotiation, generateSlugId, remove } from '../controllers/utils.js' +import { create, query, id } from '../controllers/crud.js' +import { searchAsWords, searchAsPhrase } from '../controllers/search.js' +import { deleteObj } from '../controllers/delete.js' +import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from '../controllers/update.js' +import { bulkCreate, bulkUpdate } from '../controllers/bulk.js' +import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from '../controllers/history.js' +import { release } from '../controllers/release.js' +import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand } from '../controllers/gog.js' export default { index, diff --git a/routes/delete.js b/src/routes/delete.js similarity index 93% rename from routes/delete.js rename to src/routes/delete.js index 7e747ff3..6b393b24 100644 --- a/routes/delete.js +++ b/src/routes/delete.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/history.js b/src/routes/history.js similarity index 89% rename from routes/history.js rename to src/routes/history.js index 06470da0..ace892a1 100644 --- a/routes/history.js +++ b/src/routes/history.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' router.route('/:_id') .get(controller.history) diff --git a/src/routes/home.js b/src/routes/home.js new file mode 100644 index 00000000..6b91b306 --- /dev/null +++ b/src/routes/home.js @@ -0,0 +1,13 @@ +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' +const router = express.Router() +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.sendFile('index.html', { root: path.join(__dirname, '../../public') }) +}) + +export default router diff --git a/routes/id.js b/src/routes/id.js similarity index 89% rename from routes/id.js rename to src/routes/id.js index 3c2e8988..40c9c1ef 100644 --- a/routes/id.js +++ b/src/routes/id.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' router.route('/:_id') .get(controller.id) diff --git a/routes/overwrite.js b/src/routes/overwrite.js similarity index 90% rename from routes/overwrite.js rename to src/routes/overwrite.js index 08b54fd7..0a86d840 100644 --- a/routes/overwrite.js +++ b/src/routes/overwrite.js @@ -2,7 +2,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/patchSet.js b/src/routes/patchSet.js similarity index 90% rename from routes/patchSet.js rename to src/routes/patchSet.js index ff67ec1a..23e27f38 100644 --- a/routes/patchSet.js +++ b/src/routes/patchSet.js @@ -1,9 +1,9 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' -import rest from '../rest.js' +import rest from '../utils/rest.js' router.route('/') .patch(auth.checkJwt, controller.patchSet) diff --git a/routes/patchUnset.js b/src/routes/patchUnset.js similarity index 91% rename from routes/patchUnset.js rename to src/routes/patchUnset.js index 6bdf0b65..9ef50452 100644 --- a/routes/patchUnset.js +++ b/src/routes/patchUnset.js @@ -1,9 +1,9 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' -import rest from '../rest.js' +import rest from '../utils/rest.js' router.route('/') .patch(auth.checkJwt, controller.patchUnset) diff --git a/routes/patchUpdate.js b/src/routes/patchUpdate.js similarity index 91% rename from routes/patchUpdate.js rename to src/routes/patchUpdate.js index 5df088bf..62358e31 100644 --- a/routes/patchUpdate.js +++ b/src/routes/patchUpdate.js @@ -2,8 +2,8 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' -import rest from '../rest.js' +import controller from './db-controller.js' +import rest from '../utils/rest.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/putUpdate.js b/src/routes/putUpdate.js similarity index 90% rename from routes/putUpdate.js rename to src/routes/putUpdate.js index d9397122..02dea739 100644 --- a/routes/putUpdate.js +++ b/src/routes/putUpdate.js @@ -2,7 +2,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/') diff --git a/routes/query.js b/src/routes/query.js similarity index 90% rename from routes/query.js rename to src/routes/query.js index 61c33c9b..dbff5c72 100644 --- a/routes/query.js +++ b/src/routes/query.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' router.route('/') .post(controller.query) diff --git a/routes/release.js b/src/routes/release.js similarity index 91% rename from routes/release.js rename to src/routes/release.js index 870c0d88..b2ca6569 100644 --- a/routes/release.js +++ b/src/routes/release.js @@ -2,7 +2,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' import auth from '../auth/index.js' router.route('/:_id') diff --git a/routes/search.js b/src/routes/search.js similarity index 94% rename from routes/search.js rename to src/routes/search.js index 2053bf5a..cfd842b7 100644 --- a/routes/search.js +++ b/src/routes/search.js @@ -1,6 +1,6 @@ import express from 'express' const router = express.Router() -import controller from '../db-controller.js' +import controller from './db-controller.js' router.route('/') .post(controller.searchAsWords) diff --git a/routes/since.js b/src/routes/since.js similarity index 89% rename from routes/since.js rename to src/routes/since.js index e0f7a841..d587b498 100644 --- a/routes/since.js +++ b/src/routes/since.js @@ -1,7 +1,7 @@ import express from 'express' const router = express.Router() //This controller will handle all MongoDB interactions. -import controller from '../db-controller.js' +import controller from './db-controller.js' router.route('/:_id') .get(controller.since) diff --git a/routes/static.js b/src/routes/static.js similarity index 75% rename from routes/static.js rename to src/routes/static.js index 7189dc57..86e04257 100644 --- a/routes/static.js +++ b/src/routes/static.js @@ -15,11 +15,11 @@ const __dirname = path.dirname(__filename) // public also available at `/v1` router.use(express.urlencoded({ extended: false })) -router.use(express.static(path.join(__dirname, '../public'))) +router.use(express.static(path.join(__dirname, '../../public'))) // Set default API response router.get('/', (req, res) => { - res.sendFile('index.html') // welcome page for new applications on V1 + res.sendFile('index.html', { root: path.join(__dirname, '../../public') }) // welcome page for new applications on V1 }) // Export API routes diff --git a/src/services/__tests__/bulkCreate.test.js b/src/services/__tests__/bulkCreate.test.js new file mode 100644 index 00000000..321573e1 --- /dev/null +++ b/src/services/__tests__/bulkCreate.test.js @@ -0,0 +1,36 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /bulkCreate route without auth that will use controller.bulkCreate +routeTester.use("/bulkCreate", [addAuth, controller.bulkCreate]) + +it("'/bulkCreate' route functions", async () => { + const response = await request(routeTester) + .post("/bulkCreate") + .send([{ "test": "item1" }, { "test": "item2" }]) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.statusCode).toBe(201) + expect(Array.isArray(response.body)).toBe(true) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() +}) diff --git a/src/services/__tests__/bulkUpdate.test.js b/src/services/__tests__/bulkUpdate.test.js new file mode 100644 index 00000000..d6f155e9 --- /dev/null +++ b/src/services/__tests__/bulkUpdate.test.js @@ -0,0 +1,23 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /bulkCreate route without auth that will use controller.bulkCreate +routeTester.use("/bulkUpdate", [addAuth, controller.bulkUpdate]) + +it.skip("'/bulkUpdate' route functions", async () => { + // TODO without hitting the v1/id/11111 object because it is already abused. +}) diff --git a/src/services/__tests__/client.test.txt b/src/services/__tests__/client.test.txt new file mode 100644 index 00000000..30404ce4 --- /dev/null +++ b/src/services/__tests__/client.test.txt @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/src/services/__tests__/compatability.test.txt b/src/services/__tests__/compatability.test.txt new file mode 100644 index 00000000..30404ce4 --- /dev/null +++ b/src/services/__tests__/compatability.test.txt @@ -0,0 +1 @@ +TODO \ No newline at end of file diff --git a/src/services/__tests__/create.test.js b/src/services/__tests__/create.test.js new file mode 100644 index 00000000..c18d4a1a --- /dev/null +++ b/src/services/__tests__/create.test.js @@ -0,0 +1,44 @@ +import { jest } from "@jest/globals" +import express from "express" +import request from "supertest" +import { db } from '../../db/index.js' +import controller from '../db-controller.js' + +const rerum_uri = `${process.env.RERUM_ID_PREFIX}123456` + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /create route without auth that will use controller.create +routeTester.use("/create", [addAuth, controller.create]) + +it("'/create' route functions", async () => { + const response = await request(routeTester) + .post("/create") + .send({ "test": "item" }) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.statusCode).toBe(201) + expect(response.body.test).toBe("item") + expect(response.body).toHaveProperty("__rerum") + expect(response.body._id).toBeUndefined() + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + +}) + +it.skip("Support setting valid '_id' on '/create' request body.", async () => { + // TODO +}) \ No newline at end of file diff --git a/src/services/__tests__/crud_routes_function.txt b/src/services/__tests__/crud_routes_function.txt new file mode 100644 index 00000000..b21ec9d0 --- /dev/null +++ b/src/services/__tests__/crud_routes_function.txt @@ -0,0 +1,584 @@ +*************** + + DEPRECATED + +*************** + + + +import request from 'supertest' +//Fun fact, if you don't require app, you don't get coverage even though the tests run just fine. +import app from '../../../src/index.js' +//This is so we can do Mongo specific things with the objects in this test, like actually remove them from the db. +import controller from '../db-controller.js' + +//A super fun note. If you do request(app), the tests will fail due to race conditions. +//request = request(app) +let req = request("http://localhost:3333") + +describe( + 'Test that each available endpoint succeeds given a properly formatted req and req body.', + () => { + + it('End to end /v1/id/{_id}. It should respond 404, this object does not exist.', + async () => { + const response = await req.get('/v1/id/potato') + .set('Content-Type', 'application/json; charset=utf-8') + expect(response.statusCode).toBe(404) + } + ) + + it('End to end /v1/since/{_id}. It should respond 404, this object does not exist.', + done => { + req + .get('/v1/since/potato') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(404, done) + } + ) + + it('End to end /v1/history/{_id}. It should respond 404, this object does not exist.', + done => { + req + .get('/v1/history/potato') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(404, done) + } + ) + + it('End to end /v1/id/. Forget the _id in the URL pattern. ' + + 'It should respond 404, this page/object does not exist.', + done => { + req + .get('/v1/id/') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(404, done) + } + ) + + it('End to end /v1/since/. Forget the _id in the URL pattern. ' + + 'It should respond 404, this page/object does not exist.', + done => { + req + .get('/v1/since/') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(404, done) + } + ) + + it('End to end /v1/history/. Forget the _id in the URL pattern. ' + + 'It should respond 404, this page/object does not exist.', + done => { + req + .get('/v1/history/') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(404, done) + } + ) + + it('End to end /v1/id/{_id}. Do a properly formatted GET for an object by id. ' + + 'It should respond 200 with a body that is a JSON object with an "@id" property.', + done => { + req + .get('/v1/id/11111') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["cache-control"]).toBeTruthy() + expect(response.headers["last-modified"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.headers["location"]).toBeTruthy() + expect(response.body["@id"]).toBeTruthy() + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end HEAD req to /v1/id/{_id}.' + + 'It should respond 200 and the Content-Length response header should be set.', + done => { + req + .head('/v1/id/11111') + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end /v1/since/{_id}. Do a properly formatted /since call by GETting for an existing _id. ' + + 'It should respond 200 with a body that is of type Array.' + + 'It should strip the property "_id" from the response.', + done => { + req + .get('/v1/since/11111') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) + expect(response.body[0]._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end HEAD req to /v1/since/{_id}.' + + 'It should respond 200 and the Content-Length response header should be set.', + done => { + req + .head('/v1/since/11111') + .expect(200) + .then(response => { + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["content-length"]).toBeTruthy() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end /v1/history/{_id}. Do a properly formatted /history call by GETting for an existing _id. ' + + 'It should respond 200 with a body that is of type Array.' + + 'It should strip the property "_id" from the response.', + done => { + req + .get('/v1/history/11111') + .set('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) +// cubap kill bad test for 11111 expect(response.body[0]._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end HEAD req to /v1/history/{_id}.' + + 'It should respond 200 and the Content-Length response header should be set.', + done => { + req + .head('/v1/history/11111') + .expect(200) + .then(response => { + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["content-length"]).toBeTruthy() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end /v1/api/create. Do a properly formatted /create call by POSTing a JSON body. ' + + 'The Authorization header is set, it is an access token encoded with the bot. ' + + 'It should respond with a 201 with enough JSON in the response body to discern the "@id". ' + + 'The Location header in the response should be present and populated.', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + req + .post('/v1/api/create') + .send({ "RERUM Create Test": unique }) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(201) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["location"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.body["@id"]).toBeTruthy() + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end /v1/api/bulkCreate. Do a properly formatted call by POSTing a JSON Array body. ' + + 'The Authorization header is set, it is an access token encoded with the bot. ' + + 'It should respond with a 201 with JSON in the response body matching "@id"s. ' + + 'The Link header in the response should be present and populated.', + done => { + const unique = () => new Date(Date.now()).toISOString().replace("Z", "") + req + .post('/v1/api/bulkCreate') + .send([ + { "RERUM Bulk Create Test1": unique }, + { "RERUM Bulk Create Test2": unique }, + { "RERUM Bulk Create Test3": unique }, + { "RERUM Bulk Create Test4": unique }, + ]) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(201) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["location"]).toBeUndefined() + expect(response.headers["link"]).toBeTruthy() + expect(response.body[0]).toHaveProperty("@id") + expect(response.body[0]).toHaveProperty("__rerum") + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + } + ) + + it('End to end Slug header support verification. Do a properly formatted /create call by POSTing a JSON body. ' + + 'The Location header in the response should be present and have the SLUG id.', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + const slug = `1123rcgslu1123${unique}` + //It is slightly possible this thing already exists, there could have been an error. + //Let's be super cautious and remove it first, then move on. That way we don't have to manually fix it. + controller.remove(slug).then(r => { + req + .post('/v1/api/create') + .send({ "RERUM Slug Support Test": unique }) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .set('Slug', slug) + .expect(201) + .then(response => { + expect(response.headers["location"]).toBe(response.body["@id"]) + expect(response.body.__rerum.slug).toBe(slug) + controller.remove(slug).then(s => done()) + }) + .catch(err => done(err)) + }) + .catch(err => done(err)) + }) + + it('End to end /v1/api/update. Do a properly formatted /update call by PUTing an existing entity. '+ + 'The Authorization header is set, it is an access token encoded with the bot. '+ + 'It should respond with a 200 with enough JSON in the response body to discern the "@id". '+ + 'The Location header in the response should be present and populated and not equal the originating entity "@id".', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + req + .put('/v1/api/update') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "RERUM Update Test":unique}) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.headers["location"]).toBeTruthy() + expect(response.headers["location"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + expect(response.body["@id"]).toBeTruthy() + expect(response.body["@id"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + }) + + it('End to end import functionality. Do a properly formatted /update call by PUTing an existing entity. '+ + 'If that entity has an existing id or @id property which is not from RERUM, then import it in. '+ + 'This will effectively create the object, and its __rerum.history.previous should point to the origin URI. '+ + 'The Authorization header is set, it is an access token encoded with the bot. '+ + 'It should respond with a 200 with enough JSON in the response body to discern the "@id". '+ + 'The Location header in the response should be present and populated and not equal the originating entity "@id" or "id".', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + req + .put('/v1/api/update') + .send({"id": "https://not.from.rerum/v1/api/aaaeaeaeee34345", "RERUM Import Test":unique}) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.headers["location"]).toBeTruthy() + expect(response.headers["location"]).not.toBe("https://not.from.rerum/v1/api/aaaeaeaeee34345") + expect(response.body["@id"]).toBeTruthy() + expect(response.body["@id"]).not.toBe("https://not.from.rerum/v1/api/aaaeaeaeee34345") + expect(response.body._id).toBeUndefined() + expect(response.body.id).toBeUndefined() + expect(response.body.__rerum.history.previous).toBe("https://not.from.rerum/v1/api/aaaeaeaeee34345") + done() + }) + .catch(err => done(err)) + }) + + it('End to end /v1/api/patch. Do a properly formatted /patch call by PATCHing an existing entity. '+ + 'The Authorization header is set, it is an access token encoded with the bot. '+ + 'It should respond with a 200 with enough JSON in the response body to discern the "@id". '+ + 'The Location header in the response should be present and populated and not equal the originating entity "@id".', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + req + .patch('/v1/api/patch') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_obj":unique}) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.body["@id"]).toBeTruthy() +// cubap kill bad test for 11111 expect(response.body["@id"]).not.toBe(process.env.RERUM_ID_PREFIX + "11111") +// cubap kill bad test for 11111 expect(response.body["test_obj"]).toBe(unique) + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + }) + + it('End to end /v1/api/set. Do a properly formatted /set call by PATCHing an existing entity. '+ + 'The Authorization header is set, it is an access token encoded with the bot. '+ + 'It should respond with a 200 with enough JSON in the response body to discern the "@id" and the property that was set. '+ + 'The Location header in the response should be present and populated and not equal the originating entity "@id".', + done => { + const unique = new Date(Date.now()).toISOString().replace("Z", "") + req + .patch('/v1/api/set') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_set":unique}) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.body["@id"]).toBeTruthy() + expect(response.body["@id"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + expect(response.body["test_set"]).toBe(unique) + expect(response.body._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + }) + + it('End to end /v1/api/unset. Do a properly formatted /unset call by PATCHing an existing entity. '+ + 'The Authorization header is set, it is an access token encoded with the bot. '+ + 'It should respond with a 200 with enough JSON in the response body to discern the "@id" and the absence of the unset property. '+ + 'The Location header in the response should be present and populated and not equal the originating entity "@id".', + done => { + req + .patch('/v1/api/unset') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_obj":null}) + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.body["@id"]).toBeTruthy() +// cubap kill bad test for 11111 expect(response.body["@id"]).not.toBe(process.env.RERUM_ID_PREFIX + "11111") + expect(response.body.hasOwnProperty("test_obj")).toBe(false) + expect(response.body._id).toBeUndefined() + done() + }) + }) + + it('End to end /v1/api/delete. Do a properly formatted /delete call by DELETEing an existing object. '+ + 'It will need to create an object first, then delete that object, and so must complete a /create call first. '+ + 'It will check the response to /create is 201 and the response to /delete is 204.', done => { + req + .post("/v1/api/create/") + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .send({"testing_delete":"Delete Me"}) + .expect(201) + .then(response => { + /** + * We cannot delete the same object over and over again, so we need to create an object to delete. + * Performing the extra /create in front of this adds unneceesary complexity - it has nothing to do with delete. + * TODO optimize + */ + const idToDelete = response.body["@id"].replace(process.env.RERUM_ID_PREFIX, "") + req + .delete(`/v1/api/delete/${idToDelete}`) + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .expect(204) + .then(r => { + //To be really strict, we could get the object and make sure it has __deleted. + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + done() + }) + }) + }) + + it('End to end /v1/api/query. Do a properly formatted /query call by POSTing a JSON query object. ' + + 'It should respond with a 200 and an array, even if there were no matches. ' + + 'It should strip the property "_id" from the response.' + + 'We are querying for an object we know exists, so the length of the response should be more than 0.', + done => { + req + .post('/v1/api/query') + .send({ "_id": "11111" }) + .set('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .then(response => { + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeTruthy() + expect(response.body[0]._id).toBeUndefined() + done() + }) + .catch(err => done(err)) + }) + + /* + * Under consideration, but not implemented in the API. HEAD reqs can't have bodies. + it('End to end HEAD req to /v1/api/query. '+ + */ + // 'It should respond 200 and the Content-Length response header should be set.', + // function(done) { + // req + // .head('/v1/api/query') + // .send({"_id" : "11111"}) + // .set('Content-Type', 'application/json; charset=utf-8') + // .expect(200) + // .then(response => { + // expect(response.headers["content-length"]).toBeTruthy() + // done() + // }) + // .catch(err => done(err)) + // }) + + it('End to end /v1/api/release.'+ + 'It will need to create an object first, then release that object, and so must complete a /create call first. '+ + 'It will check the response to /create is 201 and the response to /release is 200.', + done => { + req + .post("/v1/api/create/") + .set('Content-Type', 'application/json; charset=utf-8') + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .send({"testing_release":"Delete Me"}) + .expect(201) + .then(response => { + /** + * We cannot release the same object over and over again, so we need to create an object to release. + * Performing the extra /create in front of this adds unneceesary complexity - it has nothing to do with release. + * The same goes for the the remove call afterwards. + */ + const idToRelease = response.body["@id"].replace(process.env.RERUM_ID_PREFIX, "") + const slug = `rcgslu${new Date(Date.now()).toISOString().replace("Z", "")}` + controller.remove(slug).then(r => { + req + .patch(`/v1/api/release/${idToRelease}`) + .set('Authorization', `Bearer ${process.env.BOT_TOKEN}`) + .set('Slug', slug) + .expect(200) + .then(response => { + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.body.__rerum.isReleased).toBeTruthy() + expect(response.body.__rerum.slug).toBe(slug) + controller.remove(slug).then(s => done()) + }) + .catch(err => done(err)) + }) + .catch(err => done(err)) + }) + }) + + it('should use `limit` and `skip` correctly at /query', + done => { + req + .post('/v1/api/query?limit=10&skip=2') + .send({ "@id": { $exists: true } }) + .set('Content-Type', 'application/json; charset=utf-8') + .expect(200) + .then(response => { + //The following commented out headers are not what they are expected to be. TODO investigate if it matters. + //expect(response.headers["connection"]).toBe("Keep-Alive) + //expect(response.headers["keep-alive"]).toBeTruthy() + //expect(response.headers["access-control-allow-methods"]).toBeTruthy() + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["access-control-allow-origin"]).toBe("*") + expect(response.headers["access-control-expose-headers"]).toBe("*") + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeLessThanOrEqual(10) + done() + }) + .catch(err => done(err)) + }) + + }) diff --git a/src/services/__tests__/delete.test.js b/src/services/__tests__/delete.test.js new file mode 100644 index 00000000..45e5fd08 --- /dev/null +++ b/src/services/__tests__/delete.test.js @@ -0,0 +1,40 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// FIXME here we need to create something to delete in order to test this route. +routeTester.use("/create", [addAuth, controller.create]) + +// TODO test the POST delete as well +//routeTester.use("/delete", [addAuth, controller.delete]) + +// Mount our own /delete route without auth that will use controller.delete +routeTester.use("/delete/:_id", [addAuth, controller.deleteObj]) + +it("'/delete' route functions", async () => { + const created = await request(routeTester) + .post("/create") + .send({ "test": "item"}) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + + const response = await request(routeTester) + .delete(`/delete/${created.body["@id"].split("/").pop()}`) + .then(resp => resp) + .catch(err => err) + expect(response.statusCode).toBe(204) +}) diff --git a/src/services/__tests__/history.test.js b/src/services/__tests__/history.test.js new file mode 100644 index 00000000..94f5c5a7 --- /dev/null +++ b/src/services/__tests__/history.test.js @@ -0,0 +1,30 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /history route without auth that will use controller.history +routeTester.use("/history/:_id", controller.history) + +it("'/history/:id' route functions", async () => { + + const response = await request(routeTester) + .get("/history/11111") + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.statusCode).toBe(200) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) +}) diff --git a/src/services/__tests__/id.test.js b/src/services/__tests__/id.test.js new file mode 100644 index 00000000..4e64d536 --- /dev/null +++ b/src/services/__tests__/id.test.js @@ -0,0 +1,37 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /id route without auth that will use controller.id +routeTester.use("/id/:_id", controller.id) + +it("'/id/:id' route functions", async () => { + const response = await request(routeTester) + .get("/id/11111") + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.body["@id"].split("/").pop()).toBe("11111") + expect(response.body._id).toBeUndefined() + expect(response.statusCode).toBe(200) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["cache-control"]).toBeTruthy() + expect(response.headers["last-modified"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(response.headers["location"]).toBeTruthy() +}) + +it.skip("Proper '@id-id' negotation on GET by URI.", async () => { + // TODO +}) diff --git a/src/services/__tests__/idNegotiation.test.js b/src/services/__tests__/idNegotiation.test.js new file mode 100644 index 00000000..e8e8ef68 --- /dev/null +++ b/src/services/__tests__/idNegotiation.test.js @@ -0,0 +1,30 @@ +import { jest } from "@jest/globals" +import dotenv from "dotenv" +import controller from '../db-controller.js' + +it("Functional '@id-id' negotiation on objects returned.", async () => { + let negotiate = { + "@context": "http://iiif.io/api/presentation/3/context.json", + "_id": "example", + "@id": `${process.env.RERUM_ID_PREFIX}example`, + "test": "item" + } + negotiate = controller.idNegotiation(negotiate) + expect(negotiate._id).toBeUndefined() + expect(negotiate["@id"]).toBeUndefined() + expect(negotiate.id).toBe(`${process.env.RERUM_ID_PREFIX}example`) + expect(negotiate.test).toBe("item") + + let nonegotiate = { + "@context":"http://example.org/context.json", + "_id": "example", + "@id": `${process.env.RERUM_ID_PREFIX}example`, + "id": "test_example", + "test":"item" + } + nonegotiate = controller.idNegotiation(nonegotiate) + expect(nonegotiate._id).toBeUndefined() + expect(nonegotiate["@id"]).toBe(`${process.env.RERUM_ID_PREFIX}example`) + expect(nonegotiate.id).toBe("test_example") + expect(nonegotiate.test).toBe("item") +}) diff --git a/src/services/__tests__/overwrite-optimistic-locking.test.txt b/src/services/__tests__/overwrite-optimistic-locking.test.txt new file mode 100644 index 00000000..44272771 --- /dev/null +++ b/src/services/__tests__/overwrite-optimistic-locking.test.txt @@ -0,0 +1,185 @@ +import { jest } from '@jest/globals' +import express from 'express' +import request from 'supertest' + +// Create mock functions +const mockFindOne = jest.fn() +const mockReplaceOne = jest.fn() + +// Mock the database module +jest.mock('../../database/index.js', () => ({ + db: { + findOne: mockFindOne, + replaceOne: mockReplaceOne + } +})) + +// Import controller after mocking +import controller from '../db-controller.js' + +// Helper to add auth to requests +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "test-user"} + next() +} + +// Create a test Express app +const routeTester = express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our routes +routeTester.use('/overwrite', [addAuth, controller.overwrite]) +routeTester.use('/id/:_id', controller.id) + +describe('Overwrite Optimistic Locking', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should succeed when no version is specified (backwards compatibility)', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockFindOne.mockResolvedValue(mockObject) + mockReplaceOne.mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(routeTester) + .put('/overwrite') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) + + test('should succeed when correct version is provided', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockFindOne.mockResolvedValue(mockObject) + mockReplaceOne.mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(routeTester) + .put('/overwrite') + .set('If-Overwritten-Version', '2025-06-24T10:00:00') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) + + test('should fail with 409 when version mismatch occurs', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:30:00', // Different from expected + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockFindOne.mockResolvedValue(mockObject) + + const response = await request(routeTester) + .put('/overwrite') + .set('If-Overwritten-Version', '2025-06-24T10:00:00') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(409) + expect(response.body.message).toContain('Version conflict detected') + expect(response.body.currentVersion).toBe('2025-06-24T10:30:00') + }) + + test('should accept version via request body as fallback', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockFindOne.mockResolvedValue(mockObject) + mockReplaceOne.mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(routeTester) + .put('/overwrite') + .send({ + '@id': 'http://example.com/test-id', + '__expectedVersion': '2025-06-24T10:00:00', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) +}) + +describe('ID endpoint includes version header', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('should include Current-Overwritten-Version header in GET /id response', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00' + }, + data: 'some-data' + } + + mockFindOne.mockResolvedValue(mockObject) + + const response = await request(routeTester) + .get('/id/test-id') + + expect(response.status).toBe(200) + }) + + test('should include empty string for new objects', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '__rerum': { + isOverwritten: '' + }, + data: 'some-data' + } + + mockFindOne.mockResolvedValue(mockObject) + + const response = await request(routeTester) + .get('/id/test-id') + + expect(response.status).toBe(200) + }) +}) diff --git a/src/services/__tests__/overwrite.test.txt b/src/services/__tests__/overwrite.test.txt new file mode 100644 index 00000000..cd8c567f --- /dev/null +++ b/src/services/__tests__/overwrite.test.txt @@ -0,0 +1,175 @@ +import request from 'supertest' +import app from '../../../src/index.js' +import { jest } from '@jest/globals' + +// Mock the database and auth modules +jest.mock('../../db-controller.js') +jest.mock('../../auth/index.js') + +describe('Overwrite Optimistic Locking', () => { + let mockDb + let mockAuth + + beforeEach(() => { + // Reset mocks + jest.clearAllMocks() + + mockDb = require('../../db-controller.js') + mockAuth = require('../../auth/index.js') + + // Mock auth to always pass + mockAuth.checkJwt = jest.fn((req, res, next) => { + req.user = { sub: 'test-user' } + next() + }) + }) + + test('should succeed when no version is specified (backwards compatibility)', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + mockDb.replaceOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(app) + .put('/overwrite') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) + + test('should succeed when correct version is provided', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + mockDb.replaceOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(app) + .put('/overwrite') + .set('If-Overwritten-Version', '2025-06-24T10:00:00') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) + + test('should fail with 409 when version mismatch occurs', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:30:00', // Different from expected + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + + const response = await request(app) + .put('/overwrite') + .set('If-Overwritten-Version', '2025-06-24T10:00:00') + .send({ + '@id': 'http://example.com/test-id', + data: 'updated-data' + }) + + expect(response.status).toBe(409) + expect(response.body.message).toContain('Version conflict detected') + expect(response.body.currentVersion).toBe('2025-06-24T10:30:00') + }) + + test('should accept version via request body as fallback', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '@context': 'http://example.com/context', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00', + generatedBy: 'test-user' + }, + data: 'original-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + mockDb.replaceOne = jest.fn().mockResolvedValue({ modifiedCount: 1 }) + + const response = await request(app) + .put('/overwrite') + .send({ + '@id': 'http://example.com/test-id', + '__expectedVersion': '2025-06-24T10:00:00', + data: 'updated-data' + }) + + expect(response.status).toBe(200) + }) +}) + +describe('ID endpoint includes version header', () => { + let mockDb + + beforeEach(() => { + jest.clearAllMocks() + mockDb = require('../../db-controller.js') + }) + + test('should include Current-Overwritten-Version header in GET /id response', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '__rerum': { + isOverwritten: '2025-06-24T10:00:00' + }, + data: 'some-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + + const response = await request(app) + .get('/id/test-id') + + expect(response.status).toBe(200) + }) + + test('should include empty string for new objects', async () => { + const mockObject = { + _id: 'test-id', + '@id': 'http://example.com/test-id', + '__rerum': { + isOverwritten: '' + }, + data: 'some-data' + } + + mockDb.findOne = jest.fn().mockResolvedValue(mockObject) + + const response = await request(app) + .get('/id/test-id') + + expect(response.status).toBe(200) + }) +}) diff --git a/src/services/__tests__/patch.test.js b/src/services/__tests__/patch.test.js new file mode 100644 index 00000000..f7f2775d --- /dev/null +++ b/src/services/__tests__/patch.test.js @@ -0,0 +1,40 @@ +import { jest } from "@jest/globals" +import dotenv from "dotenv" +dotenv.config() +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /patch route without auth that will use controller.patch +routeTester.use("/patch", [addAuth, controller.patchUpdate]) +const unique = new Date(Date.now()).toISOString().replace("Z", "") + +it("'/patch' route functions", async () => { + const response = await request(routeTester) + .patch('/patch') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "RERUM Update Test":unique}) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.statusCode).toBe(200) + expect(response.body._id).toBeUndefined() + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + +}) diff --git a/src/services/__tests__/query.test.js b/src/services/__tests__/query.test.js new file mode 100644 index 00000000..875dd950 --- /dev/null +++ b/src/services/__tests__/query.test.js @@ -0,0 +1,37 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /query route without auth that will use controller.query +routeTester.use("/query", controller.query) + +it("'/query' route functions", async () => { + const response = await request(routeTester) + .post("/query") + .send({ "_id": "11111" }) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.statusCode).toBe(200) + expect(Array.isArray(response.body)).toBe(true) + expect(response.body.length).toBeTruthy() + expect(response.body[0]._id).toBeUndefined() + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + +}) + +it.skip("Proper '@id-id' negotation on objects returned from '/query'.", async () => { + // TODO +}) diff --git a/src/services/__tests__/release.test.js b/src/services/__tests__/release.test.js new file mode 100644 index 00000000..8d8c9b84 --- /dev/null +++ b/src/services/__tests__/release.test.js @@ -0,0 +1,45 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user so controller.create can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// FIXME here we need to create something to release in order to test this route. +routeTester.use("/create", [addAuth, controller.create]) + +// Mount our own /release route without auth that will use controller.release +routeTester.use("/release/:_id", [addAuth, controller.release]) +const slug = `rcgslu${new Date(Date.now()).toISOString().replace("Z", "")}` + +it("'/release' route functions", async () => { + + const created = await request(routeTester) + .post("/create") + .send({ "test": "item" }) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + + const slug = `rcgslu${new Date(Date.now()).toISOString().replace("Z", "")}` + + const response = await request(routeTester) + .patch(`/release/${created.body["@id"].split("/").pop()}`) + .set('Slug', slug) + .then(resp => resp) + .catch(err => err) + expect(response.statusCode).toBe(200) + expect(response.body.__rerum.isReleased).toBeTruthy() + expect(response.body.__rerum.slug).toBe(slug) + controller.remove(slug) +}) diff --git a/src/services/__tests__/set.test.js b/src/services/__tests__/set.test.js new file mode 100644 index 00000000..c2382e19 --- /dev/null +++ b/src/services/__tests__/set.test.js @@ -0,0 +1,42 @@ +import { jest } from "@jest/globals" +import dotenv from "dotenv" +dotenv.config() + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user and the controller can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /create route without auth that will use controller.create +routeTester.use("/set", [addAuth, controller.patchSet]) +const unique = new Date(Date.now()).toISOString().replace("Z", "") + +it("'/set' route functions", async () => { + const response = await request(routeTester) + .patch("/set") + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_set":unique}) + .set('Content-Type', 'application/json; charset=utf-8') + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.headers["location"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + expect(response.statusCode).toBe(200) + expect(response.body._id).toBeUndefined() + expect(response.body["test_set"]).toBe(unique) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() +}) diff --git a/src/services/__tests__/since.test.js b/src/services/__tests__/since.test.js new file mode 100644 index 00000000..2ba8c99e --- /dev/null +++ b/src/services/__tests__/since.test.js @@ -0,0 +1,29 @@ +import { jest } from "@jest/globals" + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /create route without auth that will use controller.history +routeTester.use("/since/:_id", controller.since) + +it("'/since/:id' route functions", async () => { + const response = await request(routeTester) + .get("/since/11111") + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.statusCode).toBe(200) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + expect(Array.isArray(response.body)).toBe(true) +}) \ No newline at end of file diff --git a/src/services/__tests__/unset.test.js b/src/services/__tests__/unset.test.js new file mode 100644 index 00000000..1be5f6de --- /dev/null +++ b/src/services/__tests__/unset.test.js @@ -0,0 +1,41 @@ +import { jest } from "@jest/globals" +import dotenv from "dotenv" +dotenv.config() + +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user so controller.create can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /create route without auth that will use controller.create +routeTester.use("/unset", [addAuth, controller.patchUnset]) + +it("'/unset' route functions", async () => { + const response = await request(routeTester) + .patch("/unset") + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "test_obj":null}) + .set('Content-Type', 'application/json; charset=utf-8') + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.statusCode).toBe(200) + expect(response.body._id).toBeUndefined() + expect(response.body.hasOwnProperty("test_obj")).toBe(false) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() +}) + diff --git a/src/services/__tests__/update.test.js b/src/services/__tests__/update.test.js new file mode 100644 index 00000000..3d4dbeae --- /dev/null +++ b/src/services/__tests__/update.test.js @@ -0,0 +1,43 @@ +import { jest } from "@jest/globals" +import dotenv from "dotenv" +dotenv.config() +// Only real way to test an express route is to mount it and call it so that we can use the req, res, next. +import express from "express" +import request from "supertest" +import controller from '../db-controller.js' + +// Here is the auth mock so we get a req.user so controller.create can function without a NPE. +const addAuth = (req, res, next) => { + req.user = {"http://store.rerum.io/agent": "https://store.rerum.io/v1/id/agent007"} + next() +} + +const routeTester = new express() +routeTester.use(express.json()) +routeTester.use(express.urlencoded({ extended: false })) + +// Mount our own /create route without auth that will use controller.create +routeTester.use("/update", [addAuth, controller.putUpdate]) +const unique = new Date(Date.now()).toISOString().replace("Z", "") + +it("'/update' route functions", async () => { + + const response = await request(routeTester) + .put('/update') + .send({"@id":`${process.env.RERUM_ID_PREFIX}11111`, "RERUM Update Test":unique}) + .set("Content-Type", "application/json") + .then(resp => resp) + .catch(err => err) + expect(response.header.location).toBe(response.body["@id"]) + expect(response.headers["location"]).not.toBe(`${process.env.RERUM_ID_PREFIX}11111`) + expect(response.statusCode).toBe(200) + expect(response.body._id).toBeUndefined() + expect(response.body["RERUM Update Test"]).toBe(unique) + expect(response.headers["content-length"]).toBeTruthy() + expect(response.headers["content-type"]).toBeTruthy() + expect(response.headers["date"]).toBeTruthy() + expect(response.headers["etag"]).toBeTruthy() + expect(response.headers["allow"]).toBeTruthy() + expect(response.headers["link"]).toBeTruthy() + +}) diff --git a/src/services/api-routes.js b/src/services/api-routes.js new file mode 100644 index 00000000..e5cdc743 --- /dev/null +++ b/src/services/api-routes.js @@ -0,0 +1,86 @@ +#!/usr/bin/env node + +/** + * This module is used to define the routes of the various HITTP request that come to the app. + * Since this app functions as an API layer, it controls RESTful flows. Make sure to send a RESTful + * status code and response message. + * + * It is used as middleware and so has access to the http module request and response objects, as well as next() + * + * @author thehabes + */ +import express from 'express' +const router = express.Router() +import staticRouter from './static.js'; +// Support GET requests like v1/id/{object id} +import idRouter from './id.js'; +// Support older style API calls through rewrite. +import compatabilityRouter from './compatability.js'; +// Support POST requests with JSON bodies used for passing queries though to the database. +import queryRouter from './query.js'; +// Support POST requests with string or JSON bodies used for passing $search queries though to the database indexes. +import searchRouter from './search.js'; +// Support POST requests with JSON bodies used for establishing new objects. +import createRouter from './create.js'; +// Support POST requests with JSON Array bodies used for establishing new objects. +import bulkCreateRouter from './bulkCreate.js'; +//Support PUT requests with JSON Array bodies used for updating a number of existing objects. +import bulkUpdateRouter from './bulkUpdate.js'; +// Support DELETE requests like v1/delete/{object id} to mark an object as __deleted. +import deleteRouter from './delete.js'; +// Support POST requests with JSON bodies used for replacing some existing object. +import overwriteRouter from './overwrite.js'; +// Support PUT requests with JSON bodies used for versioning an existing object through replacement. +import updateRouter from './putUpdate.js'; +// Support PATCH requests with JSON bodies used for versioning an existing object through key/value setting. +import patchRouter from './patchUpdate.js'; +// Support PATCH requests with JSON bodies used for creating new keys in some existing object. +import setRouter from './patchSet.js'; +// Support PATCH requests with JSON bodies used for removing keys in some existing object. +import unsetRouter from './patchUnset.js'; +// Support PATCH requests (that may contain a Slug header or ?slug parameter) to mark as object as released. +import releaseRouter from './release.js'; +// Support GET requests like v1/since/{object id} to discover all versions from all sources updating this version. +import sinceRouter from './since.js'; +// Support GET requests like v1/history/{object id} to discover all previous versions tracing back to the prime. +import historyRouter from './history.js'; + +router.use(staticRouter) +router.use('/id',idRouter) +router.use('/api', compatabilityRouter) +router.use('/api/query', queryRouter) +router.use('/api/search', searchRouter) +router.use('/api/create', createRouter) +router.use('/api/bulkCreate', bulkCreateRouter) +router.use('/api/bulkUpdate', bulkUpdateRouter) +router.use('/api/delete', deleteRouter) +router.use('/api/overwrite', overwriteRouter) +router.use('/api/update', updateRouter) +router.use('/api/patch', patchRouter) +router.use('/api/set', setRouter) +router.use('/api/unset', unsetRouter) +router.use('/api/release', releaseRouter) +// Set default API response +router.get('/api', (req, res) => { + res.json({ + message: 'Welcome to v1 in nodeJS! Below are the available endpoints, used like /v1/api/{endpoint}', + endpoints: { + "/create": "POST - Create a new object.", + "/update": "PUT - Update the body an existing object.", + "/patch": "PATCH - Update the properties of an existing object.", + "/set": "PATCH - Update the body an existing object by adding a new property.", + "/unset": "PATCH - Update the body an existing object by removing an existing property.", + "/delete": "DELETE - Mark an object as deleted.", + "/query": "POST - Supply a JSON object to match on, and query the db for an array of matches.", + "/release": "POST - Lock a JSON object from changes and guarantee the content and URI.", + "/overwrite": "POST - Update a specific document in place, overwriting the existing body." + } + }) +}) +router.use('/since', sinceRouter) +router.use('/history', historyRouter) + +// Note that error responses are handled by rest.js through app.js. No need to do anything with them here. + +// Export API routes +export default router diff --git a/src/services/crudService.js b/src/services/crudService.js new file mode 100644 index 00000000..cd014dbc --- /dev/null +++ b/src/services/crudService.js @@ -0,0 +1,52 @@ +/** + * CRUD Service - Business logic and database operations for CRUD operations + * This service handles all business logic and database interactions, + * separate from HTTP request/response handling. + * + * @author thehabes, cubap, RERUM team + */ +import { db } from '../db/index.js' +import { _contextid } from '../controllers/utils.js' + +/** + * Get an object by its ID or slug + * Business logic: Query database and return the object if found + * + * @param {string} id - The _id or slug to search for + * @returns {Promise} The found object or null if not found + * @throws {Error} Database errors + * @throws {{ message: string, status: number }} 400 when id is falsy (explicit validation; /v1/id/{blank} is handled by 404 elsewhere) + */ +const getIdById = async function(id) { + if (!id) { + throw { + message: "ID parameter is required", + status: 400 + } + } + + const match = await db.findOne({ + "$or": [ + {"_id": id}, + {"__rerum.slug": id} + ] + }) + + return match +} + +/** + * Get the location header value for an object + * Determines whether to use 'id' or '@id' based on context + * + * @param {Object} match - The object to get location for + * @returns {string} The location URL + */ +const getLocationHeader = function(match) { + return _contextid(match["@context"]) ? match.id : match["@id"] +} + +export { + getIdById, + getLocationHeader +} diff --git a/src/services/db-controller.js b/src/services/db-controller.js new file mode 100644 index 00000000..ce54724d --- /dev/null +++ b/src/services/db-controller.js @@ -0,0 +1,48 @@ +#!/usr/bin/env node + +/** + * Main controller aggregating all RERUM operations + * This file now imports from organized controller modules + * @author Claude Sonnet 4, cubap, thehabes + */ + +// Import controller modules +import { index, idNegotiation, generateSlugId, remove } from '../controllers/utils.js' +import { create, query, id } from '../controllers/crud.js' +import { searchAsWords, searchAsPhrase } from '../controllers/search.js' +import { deleteObj } from '../controllers/delete.js' +import { putUpdate, patchUpdate, patchSet, patchUnset, overwrite } from '../controllers/update.js' +import { bulkCreate, bulkUpdate } from '../controllers/bulk.js' +import { since, history, idHeadRequest, queryHeadRequest, sinceHeadRequest, historyHeadRequest } from '../controllers/history.js' +import { release } from '../controllers/release.js' +import { _gog_fragments_from_manuscript, _gog_glosses_from_manuscript, expand } from '../controllers/gog.js' + +export default { + index, + create, + deleteObj, + putUpdate, + patchUpdate, + patchSet, + patchUnset, + generateSlugId, + overwrite, + release, + query, + searchAsWords, + searchAsPhrase, + id, + bulkCreate, + bulkUpdate, + idHeadRequest, + queryHeadRequest, + since, + history, + sinceHeadRequest, + historyHeadRequest, + remove, + _gog_glosses_from_manuscript, + _gog_fragments_from_manuscript, + idNegotiation, + expand +} diff --git a/src/services/history.js b/src/services/history.js new file mode 100644 index 00000000..ace892a1 --- /dev/null +++ b/src/services/history.js @@ -0,0 +1,15 @@ +import express from 'express' +const router = express.Router() +//This controller will handle all MongoDB interactions. +import controller from './db-controller.js' + +router.route('/:_id') + .get(controller.history) + .head(controller.historyHeadRequest) + .all((req, res, next) => { + res.statusMessage = 'Improper request method, please use GET.' + res.status(405) + next(res) + }) + +export default router diff --git a/src/services/id.js b/src/services/id.js new file mode 100644 index 00000000..40c9c1ef --- /dev/null +++ b/src/services/id.js @@ -0,0 +1,16 @@ +import express from 'express' +const router = express.Router() +//This controller will handle all MongoDB interactions. +import controller from './db-controller.js' + +router.route('/:_id') + .get(controller.id) + .head(controller.idHeadRequest) + .all((req, res, next) => { + res.statusMessage = 'Improper request method, please use GET.' + res.status(405) + next(res) + }) + +export default router + diff --git a/src/services/index.js b/src/services/index.js new file mode 100644 index 00000000..6b91b306 --- /dev/null +++ b/src/services/index.js @@ -0,0 +1,13 @@ +import express from 'express' +import path from 'path' +import { fileURLToPath } from 'url' +const router = express.Router() +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +/* GET home page. */ +router.get('/', (req, res, next) => { + res.sendFile('index.html', { root: path.join(__dirname, '../../public') }) +}) + +export default router diff --git a/src/services/query.js b/src/services/query.js new file mode 100644 index 00000000..dbff5c72 --- /dev/null +++ b/src/services/query.js @@ -0,0 +1,15 @@ +import express from 'express' +const router = express.Router() +//This controller will handle all MongoDB interactions. +import controller from './db-controller.js' + +router.route('/') + .post(controller.query) + .head(controller.queryHeadRequest) + .all((req, res, next) => { + res.statusMessage = 'Improper request method for requesting objects with matching properties. Please use POST.' + res.status(405) + next(res) + }) + +export default router diff --git a/src/services/search.js b/src/services/search.js new file mode 100644 index 00000000..cfd842b7 --- /dev/null +++ b/src/services/search.js @@ -0,0 +1,24 @@ +import express from 'express' +const router = express.Router() +import controller from './db-controller.js' + +router.route('/') + .post(controller.searchAsWords) + .all((req, res, next) => { + res.statusMessage = 'Improper request method for search. Please use POST.' + res.status(405) + next(res) + }) + +router.route('/phrase') + .post(controller.searchAsPhrase) + .all((req, res, next) => { + res.statusMessage = 'Improper request method for search. Please use POST.' + res.status(405) + next(res) + }) + +// Note that there are more search functions available in the controller, such as controller.searchFuzzily +// They can be used through additional endpoints here when we are ready. + +export default router \ No newline at end of file diff --git a/src/services/since.js b/src/services/since.js new file mode 100644 index 00000000..d587b498 --- /dev/null +++ b/src/services/since.js @@ -0,0 +1,15 @@ +import express from 'express' +const router = express.Router() +//This controller will handle all MongoDB interactions. +import controller from './db-controller.js' + +router.route('/:_id') + .get(controller.since) + .head(controller.sinceHeadRequest) + .all((req, res, next) => { + res.statusMessage = 'Improper request method, please use GET.' + res.status(405) + next(res) + }) + +export default router diff --git a/src/services/static.js b/src/services/static.js new file mode 100644 index 00000000..86e04257 --- /dev/null +++ b/src/services/static.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +/** + * This module is used to define the routes of static resources available in `/public` + * but also under `/v1` paths. + * + * @author cubap + */ +import express from 'express' +const router = express.Router() +import path from 'path' +import { fileURLToPath } from 'url' +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// public also available at `/v1` +router.use(express.urlencoded({ extended: false })) +router.use(express.static(path.join(__dirname, '../../public'))) + +// Set default API response +router.get('/', (req, res) => { + res.sendFile('index.html', { root: path.join(__dirname, '../../public') }) // welcome page for new applications on V1 +}) + +// Export API routes +export default router diff --git a/utils.js b/src/utils/index.js similarity index 100% rename from utils.js rename to src/utils/index.js diff --git a/rest.js b/src/utils/rest.js similarity index 100% rename from rest.js rename to src/utils/rest.js