Skip to content

Commit

Permalink
Merge branch 'main' into jh/read-me-follow-ons
Browse files Browse the repository at this point in the history
  • Loading branch information
jamdelion committed Jan 28, 2025
2 parents c2ac9b5 + b05d6b4 commit 552f611
Show file tree
Hide file tree
Showing 121 changed files with 2,877 additions and 391 deletions.
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ FILE_API_KEY_EPSOM_EWELL=👻
FILE_API_KEY_MEDWAY=👻
FILE_API_KEY_GATESHEAD=👻
FILE_API_KEY_DONCASTER=👻
FILE_API_KEY_GLOUCESTER=👻
FILE_API_KEY_TEWKESBURY=👻


# Editor
EDITOR_URL_EXT=http://localhost:3000
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,12 @@ hasura.planx.uk/.env.test
/playwright-report/
/playwright/.cache/
api.planx.uk/tmp/

# Python
.python-version
__pycache__
.venv/
.ruff_cache/

# Ignore certificate files
**/*.chain
Expand Down
4 changes: 3 additions & 1 deletion api.planx.uk/.env.test.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,11 @@ FILE_API_KEY_EPSOM_EWELL=👻
FILE_API_KEY_MEDWAY=👻
FILE_API_KEY_GATESHEAD=👻
FILE_API_KEY_DONCASTER=👻
FILE_API_KEY_GLOUCESTER=👻
FILE_API_KEY_TEWKESBURY=👻

# Editor
EDITOR_URL_EXT=example.com
EDITOR_URL_EXT=https://www.example.com

# Hasura
HASURA_GRAPHQL_URL=http://hasura:8080/v1/graphql
Expand Down
4 changes: 2 additions & 2 deletions api.planx.uk/client/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { CoreDomainClient } from "@opensystemslab/planx-core";
import { getClient } from "./index.js";
import { userContext } from "../modules/auth/middleware.js";
import { getJWT } from "../tests/mockJWT.js";
import { getTestJWT } from "../tests/mockJWT.js";

test("getClient() throws an error if a store is not set", () => {
expect(() => getClient()).toThrow();
Expand All @@ -12,7 +12,7 @@ test("getClient() returns a client if store is set", () => {
getStoreMock.mockReturnValue({
user: {
sub: "123",
jwt: getJWT({ role: "teamEditor" }),
jwt: getTestJWT({ role: "teamEditor" }),
},
});

Expand Down
53 changes: 53 additions & 0 deletions api.planx.uk/errors/requestHandlers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { ErrorRequestHandler } from "express";
import { ServerError } from "./serverError.js";
import airbrake from "../airbrake.js";

/**
* Check for expired JWTs, redirect to /logout if found
*/
export const expiredJWTHandler: ErrorRequestHandler = (
errorObject,
_res,
res,
next,
) => {
const isJWTExpiryError =
errorObject?.name === "UnauthorizedError" &&
errorObject?.message === "jwt expired";

if (!isJWTExpiryError) return next(errorObject);

const logoutPage = new URL("/logout", process.env.EDITOR_URL_EXT!).toString();
return res.redirect(logoutPage);
};

/**
* Fallback error handler
* Must be final Express middleware
*/
export const errorHandler: ErrorRequestHandler = (
errorObject,
_req,
res,
_next,
) => {
const { status = 500, message = "Something went wrong" } = (() => {
if (
airbrake &&
(errorObject instanceof Error || errorObject instanceof ServerError)
) {
airbrake.notify(errorObject);
return {
...errorObject,
message: errorObject.message.concat(", this error has been logged"),
};
} else {
console.log(errorObject);
return errorObject;
}
})();

res.status(status).send({
error: message,
});
};
56 changes: 56 additions & 0 deletions api.planx.uk/modules/analytics/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,26 @@ openapi: 3.1.0
info:
title: Plan✕ API
version: 0.1.0

tags:
- name: analytics

components:
responses:
AnalyticsResponse:
description: Successful response with no content. Not awaited from server as endpoint is called via the Beacon API

schemas:
NewCollection:
type: object
properties:
description:
type: string
description: Optional description for the collection
parentId:
type: integer
description: Optional ID of the parent collection

paths:
/analytics/log-user-exit:
post:
Expand All @@ -18,6 +32,7 @@ paths:
responses:
"204":
$ref: "#/components/responses/AnalyticsResponse"

/analytics/log-user-resume:
post:
summary: Log user resume
Expand All @@ -27,3 +42,44 @@ paths:
responses:
"204":
$ref: "#/components/responses/AnalyticsResponse"

/metabase/collection/{slug}:
post:
summary: Create new Metabase collection
description: Creates a new collection in Metabase if it doesn't already exist
tags:
- metabase
parameters:
- name: slug
in: path
required: true
schema:
type: string
description: PlanX team slug
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/NewCollection"
responses:
"201":
description: Collection created successfully
content:
application/json:
schema:
type: object
properties:
data:
type: integer
description: Metabase collection ID
"400":
description: Bad request or collection creation failed
content:
application/json:
schema:
type: object
properties:
error:
type: string
description: Error message
189 changes: 189 additions & 0 deletions api.planx.uk/modules/analytics/metabase/collection/collection.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,138 @@
import { createTeamCollection } from "./service.js";
import { getCollection } from "./getCollection.js";
import nock from "nock";
import { MetabaseError } from "../shared/client.js";
import { $api } from "../../../../client/index.js";
import { updateMetabaseId } from "./updateMetabaseId.js";
import { getTeamIdAndMetabaseId } from "./getTeamIdAndMetabaseId.js";
import { createCollection } from "./createCollection.js";

describe("createTeamCollection", () => {
beforeEach(() => {
nock.cleanAll();
});

test("creates new collection when metabase ID doesn't exist", async () => {
// Mock getTeamIdAndMetabaseId response with null metabase_id
vi.spyOn($api.client, "request").mockResolvedValueOnce({
teams: [
{
id: 26,
slug: "barnet",
metabase_id: null,
},
],
});

// Mock Metabase API calls
const metabaseMock = nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
name: "Barnet",
})
.reply(200, {
id: 123,
name: "Barnet",
});

const collectionId = await createCollection({
name: "Barnet",
});

expect(collectionId).toBe(123);
expect(metabaseMock.isDone()).toBe(true);
});

test("successfully places new collection in parent", async () => {
// Mock updateMetabaseId response
vi.spyOn($api.client, "request").mockResolvedValueOnce({
update_teams: {
returning: [
{
id: 26,
name: "Barnet",
slug: "barnet",
metabase_id: 123,
},
],
},
});

const testName = "Example Council";
const metabaseMock = nock(process.env.METABASE_URL_EXT!);

// Mock collection creation endpoint
metabaseMock
.post("/api/collection/", {
name: testName,
parentId: 100,
})
.reply(200, {
id: 123,
name: testName,
parentId: 100,
});

// Mock GET request for verifying the new collection
metabaseMock.get("/api/collection/123").reply(200, {
id: 123,
name: testName,
parentId: 100,
});

const collectionId = await createCollection({
name: testName,
parentId: 100,
});

// Check the ID is returned correctly
expect(collectionId).toBe(123);

// Verify the collection details using the service function
const collection = await getCollection(collectionId);
expect(collection.parentId).toBe(100);
expect(metabaseMock.isDone()).toBe(true);
});

test("returns collection correctly no matter collection slug case", async () => {
vi.spyOn($api.client, "request").mockResolvedValueOnce({
teams: [
{
id: 26,
name: "Barnet",
slug: "barnet",
metabaseId: 20,
},
],
});

const collection = await getTeamIdAndMetabaseId("BARNET");
expect(collection.metabaseId).toBe(20);
});

test("throws error if network failure", async () => {
nock(process.env.METABASE_URL_EXT!)
.get("/api/collection/")
.replyWithError("Network error occurred");

await expect(
createCollection({
name: "Test Collection",
}),
).rejects.toThrow("Network error occurred");
});

test("throws error if API error", async () => {
nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(400, {
message: "Bad request",
});

await expect(
createCollection({
name: "Test Collection",
}),
).rejects.toThrow(MetabaseError);
});
});

describe("getTeamIdAndMetabaseId", () => {
beforeEach(() => {
Expand All @@ -12,6 +144,7 @@ describe("getTeamIdAndMetabaseId", () => {
teams: [
{
id: 26,
name: "Barnet",
slug: "barnet",
metabaseId: 20,
},
Expand Down Expand Up @@ -90,3 +223,59 @@ describe("updateMetabaseId", () => {
await expect(updateMetabaseId(1, 123)).rejects.toThrow("GraphQL error");
});
});

describe("edge cases", () => {
beforeEach(() => {
nock.cleanAll();
vi.clearAllMocks();
vi.resetAllMocks();
});

test("handles missing slug", async () => {
await expect(
createTeamCollection({
slug: "",
}),
).rejects.toThrow();
});

test("handles name with special characters", async () => {
const specialName = "@#$%^&*";

nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(200, []);

nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
name: specialName,
})
.reply(200, {
id: 789,
name: specialName,
});

const collection = await createCollection({
name: specialName,
});
expect(collection).toBe(789);
});

test("handles very long slugs", async () => {
const longSlug = "A".repeat(101);

nock(process.env.METABASE_URL_EXT!).get("/api/collection/").reply(200, []);

nock(process.env.METABASE_URL_EXT!)
.post("/api/collection/", {
slug: longSlug,
})
.reply(400, {
message: "Slug too long",
});

await expect(
createTeamCollection({
slug: longSlug,
}),
).rejects.toThrow();
});
});
Loading

0 comments on commit 552f611

Please sign in to comment.