Merge pull request #18 from brendan-ch/feat/notification-service-integration

feat/notification-service-integration
This commit is contained in:
2025-02-04 11:45:17 -08:00
committed by GitHub
16 changed files with 321 additions and 58 deletions

6
package-lock.json generated
View File

@@ -9,7 +9,6 @@
"version": "1.0.0",
"dependencies": {
"@apollo/server": "^4.11.2",
"@types/jsonwebtoken": "^9.0.8",
"graphql": "^16.10.0",
"jsonwebtoken": "^9.0.2"
},
@@ -18,6 +17,7 @@
"@graphql-codegen/typescript": "4.1.2",
"@graphql-codegen/typescript-resolvers": "4.4.1",
"@jest/globals": "^29.7.0",
"@types/jsonwebtoken": "^9.0.8",
"@types/node": "^22.10.2",
"jest": "^29.7.0",
"ts-jest": "^29.2.5",
@@ -3571,6 +3571,7 @@
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.8.tgz",
"integrity": "sha512-7fx54m60nLFUVYlxAB1xpe9CBWX2vSrk50Y6ogRJ1v5xxtba7qXTg5BgYDN5dq+yuQQ9HaVlHJyAAt1/mxryFg==",
"dev": true,
"dependencies": {
"@types/ms": "*",
"@types/node": "*"
@@ -3589,7 +3590,8 @@
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"dev": true
},
"node_modules/@types/node": {
"version": "22.10.2",

View File

@@ -1,8 +1,4 @@
type Query {
systems: [System!]!
system(id: ID): System
}
# Base types
type System {
id: ID!
name: String!
@@ -62,3 +58,33 @@ type Shuttle {
etas: [ETA!]
eta(forStopId: ID): ETA
}
# Queries
type Query {
systems: [System!]!
system(id: ID): System
}
# Mutations
type Mutation {
scheduleNotification(input: NotificationInput!): NotificationResponse!
cancelNotification(input: NotificationInput!): NotificationResponse!
}
input NotificationInput {
deviceId: ID!
shuttleId: ID!
stopId: ID!
}
type NotificationResponse {
success: Boolean!
message: String!
data: Notification
}
type Notification {
deviceId: ID!
shuttleId: ID!
stopId: ID!
}

View File

@@ -7,6 +7,7 @@ import { OrderedStopResolvers } from "./resolvers/OrderedStopResolvers";
import { StopResolvers } from "./resolvers/StopResolvers";
import { ShuttleResolvers } from "./resolvers/ShuttleResolvers";
import { RouteResolvers } from "./resolvers/RouteResolvers";
import { MutationResolvers } from "./resolvers/MutationResolvers";
export const MergedResolvers: Resolvers<ServerContext> = {
...QueryResolvers,
@@ -16,4 +17,5 @@ export const MergedResolvers: Resolvers<ServerContext> = {
...StopResolvers,
...OrderedStopResolvers,
...EtaResolvers,
};
...MutationResolvers,
};

View File

@@ -1,5 +1,7 @@
import { GetterRepository } from "./repositories/GetterRepository";
import { NotificationService } from "./services/NotificationService";
import { GetterSetterRepository } from "./repositories/GetterSetterRepository";
export interface ServerContext {
repository: GetterRepository;
}
repository: GetterSetterRepository;
notificationService: NotificationService;
}

View File

@@ -5,6 +5,7 @@ import { MergedResolvers } from "./MergedResolvers";
import { ServerContext } from "./ServerContext";
import { UnoptimizedInMemoryRepository } from "./repositories/UnoptimizedInMemoryRepository";
import { TimedApiBasedRepositoryLoader } from "./loaders/TimedApiBasedRepositoryLoader";
import { NotificationService } from "./services/NotificationService";
const typeDefs = readFileSync("./schema.graphqls", "utf8");
@@ -16,13 +17,13 @@ async function main() {
});
const repository = new UnoptimizedInMemoryRepository();
// await loadTestData(repository);
const repositoryDataUpdater = new TimedApiBasedRepositoryLoader(
repository
);
await repositoryDataUpdater.start();
const notificationService = new NotificationService(repository);
const { url } = await startStandaloneServer(server, {
listen: {
port: process.env.PORT ? parseInt(process.env.PORT) : 4000,
@@ -30,6 +31,7 @@ async function main() {
context: async ({ req, res }) => {
return {
repository,
notificationService,
}
},
});

View File

@@ -0,0 +1,47 @@
import { NotificationInput, NotificationResponse, Resolvers } from "../generated/graphql";
import { ServerContext } from "../ServerContext";
export const MutationResolvers: Resolvers<ServerContext> = {
Mutation: {
scheduleNotification: async (_parent, args, context, _info) => {
const shuttle = await context.repository.getShuttleById(args.input.shuttleId);
if (!shuttle) {
return {
message: "Shuttle ID doesn't exist",
success: false,
}
}
const stop = await context.repository.getStopById(args.input.stopId);
if (!stop) {
return {
message: "Stop ID doesn't exist",
success: false,
}
}
await context.notificationService.scheduleNotification(args.input);
const response: NotificationResponse = {
message: "Notification scheduled",
success: true,
data: args.input,
}
return response;
},
cancelNotification: async (_parent, args, context, _info) => {
if (context.notificationService.isNotificationScheduled(args.input)) {
await context.notificationService.cancelNotificationIfExists(args.input);
return {
success: true,
message: "Notification cancelled",
data: args.input,
}
}
return {
success: false,
message: "Notification doesn't exist"
}
},
},
}

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { IEta, IShuttle, IStop, ISystem } from "../../src/entities/entities";
import {
addMockEtaToRepository, addMockShuttleToRepository,
@@ -9,6 +9,7 @@ import {
import assert = require("node:assert");
describe("EtaResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
@@ -24,7 +25,7 @@ describe("EtaResolvers", () => {
});
async function getResponseForEtaQuery(query: string) {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -87,4 +88,4 @@ describe("EtaResolvers", () => {
expect(eta.shuttle.id).toEqual(expectedEta.shuttleId);
});
});
});
});

View File

@@ -0,0 +1,156 @@
import { describe, expect, it } from "@jest/globals";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import {
addMockShuttleToRepository,
addMockStopToRepository,
addMockSystemToRepository
} from "../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
import { NotificationInput } from "../../src/generated/graphql";
describe("MutationResolvers", () => {
const holder = setupTestServerHolder()
const context = setupTestServerContext();
async function getServerResponse(query: string, notificationInput: { deviceId: string; shuttleId: string; stopId: string }) {
return await holder.testServer.executeOperation({
query,
variables: {
input: notificationInput,
}
}, {
contextValue: context
});
}
describe("scheduleNotification", () => {
const query = `
mutation ScheduleNotification($input: NotificationInput!) {
scheduleNotification(input: $input) {
success
message
data {
deviceId
shuttleId
stopId
}
}
}
`
function assertFailedResponse(response: any, notificationInput: NotificationInput) {
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.scheduleNotification as any;
expect(notificationResponse.success).toBe(false);
expect(context.notificationService.isNotificationScheduled(notificationInput)).toBe(false);
}
it("adds a notification to the notification service", async () => {
const system = await addMockSystemToRepository(context.repository);
const shuttle = await addMockShuttleToRepository(context.repository, system.id);
const stop = await addMockStopToRepository(context.repository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: stop.id,
};
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.scheduleNotification as any;
expect(notificationResponse?.success).toBe(true);
expect(notificationResponse?.data).toEqual(notificationInput);
expect(context.notificationService.isNotificationScheduled(notificationInput)).toBe(true);
});
it("fails if the shuttle ID doesn't exist", async () => {
const system = await addMockSystemToRepository(context.repository);
const stop = await addMockStopToRepository(context.repository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: "1",
stopId: stop.id,
}
const response = await getServerResponse(query, notificationInput);
assertFailedResponse(response, notificationInput);
});
it("fails if the stop ID doesn't exist", async () => {
const system = await addMockSystemToRepository(context.repository);
const shuttle = await addMockShuttleToRepository(context.repository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: "1",
}
const response = await getServerResponse(query, notificationInput);
assertFailedResponse(response, notificationInput);
});
});
describe("cancelNotification", () => {
const query = `
mutation CancelNotification($input: NotificationInput!) {
cancelNotification(input: $input) {
success
message
data {
deviceId
shuttleId
stopId
}
}
}
`
it("removes the notification from the notification service", async () => {
const system = await addMockSystemToRepository(context.repository);
const shuttle = await addMockShuttleToRepository(context.repository, system.id);
const stop = await addMockStopToRepository(context.repository, system.id);
const notificationInput = {
deviceId: "1",
shuttleId: shuttle.id,
stopId: stop.id,
}
await context.notificationService.scheduleNotification(notificationInput);
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.cancelNotification as any;
expect(notificationResponse.success).toBe(true);
expect(notificationResponse.data).toEqual(notificationInput);
expect(context.notificationService.isNotificationScheduled(notificationInput)).toBe(false);
});
it("fails if the notification doesn't exist", async () => {
const notificationInput = {
deviceId: "1",
shuttleId: "1",
stopId: "1",
}
const response = await getServerResponse(query, notificationInput);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const notificationResponse = response.body.singleResult.data?.cancelNotification as any;
expect(notificationResponse.success).toBe(false);
});
});
});

View File

@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { IRoute, IStop, ISystem } from "../../src/entities/entities";
import { generateMockOrderedStops, generateMockStops } from "../testHelpers/mockDataGenerators";
import { addMockRouteToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
describe("OrderedStopResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
@@ -59,7 +60,7 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -131,7 +132,7 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -206,7 +207,7 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -256,7 +257,7 @@ describe("OrderedStopResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,

View File

@@ -1,12 +1,13 @@
import { describe, expect, it } from "@jest/globals";
import { generateMockSystems } from "../testHelpers/mockDataGenerators";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import assert = require("node:assert");
// See Apollo documentation for integration test guide
// https://www.apollographql.com/docs/apollo-server/testing/testing
describe("QueryResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
async function addMockSystems() {
@@ -30,7 +31,7 @@ describe("QueryResolvers", () => {
}
`;
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
}, {
contextValue: {
@@ -59,7 +60,7 @@ describe("QueryResolvers", () => {
const systems = await addMockSystems();
const systemToGet = systems[1];
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
id: systemToGet.id,
@@ -76,7 +77,7 @@ describe("QueryResolvers", () => {
});
it("returns null if there is no system", async () => {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
id: "nonexistent-id",
@@ -92,4 +93,4 @@ describe("QueryResolvers", () => {
expect(response.body.singleResult.data?.system).toBeNull();
});
});
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import {
addMockRouteToRepository,
addMockStopToRepository,
@@ -10,6 +10,7 @@ import { IRoute, IStop, ISystem } from "../../src/entities/entities";
import assert = require("node:assert");
describe("RouteResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
@@ -40,7 +41,7 @@ describe("RouteResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -95,7 +96,7 @@ describe("RouteResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -145,4 +146,4 @@ describe("RouteResolvers", () => {
expect(orderedStop).toBeNull();
});
});
});
});

View File

@@ -1,12 +1,13 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { generateMockEtas, generateMockRoutes } from "../testHelpers/mockDataGenerators";
import { IShuttle, ISystem } from "../../src/entities/entities";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { addMockShuttleToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
describe("ShuttleResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
@@ -47,7 +48,7 @@ describe("ShuttleResolvers", () => {
const mockEta = etas[1];
// Act
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -68,7 +69,7 @@ describe("ShuttleResolvers", () => {
});
it("returns null if it doesn't exist", async () => {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -106,7 +107,7 @@ describe("ShuttleResolvers", () => {
it("returns associated ETAs if they exist for the shuttle", async () => {
const etas = await addMockEtas(mockShuttle.id);
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -125,7 +126,7 @@ describe("ShuttleResolvers", () => {
});
it("returns empty array if no ETAs exist", async () => {
const response = await context.testServer.executeOperation({
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -164,7 +165,7 @@ describe("ShuttleResolvers", () => {
`
async function getResponseForQuery() {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -197,4 +198,4 @@ describe("ShuttleResolvers", () => {
});
});
});
});

View File

@@ -1,11 +1,12 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { generateMockEtas, generateMockOrderedStops } from "../testHelpers/mockDataGenerators";
import { IStop, ISystem } from "../../src/entities/entities";
import { addMockStopToRepository, addMockSystemToRepository } from "../testHelpers/repositorySetupHelpers";
import assert = require("node:assert");
describe("StopResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockStop: IStop;
@@ -17,7 +18,7 @@ describe("StopResolvers", () => {
})
async function getResponseForQuery(query: string) {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -105,4 +106,4 @@ describe("StopResolvers", () => {
expect((response.body.singleResult.data as any).system.stop.etas).toHaveLength(0);
});
});
});
});

View File

@@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it } from "@jest/globals";
import { setupTestServerContext } from "../testHelpers/apolloTestServerHelpers";
import { setupTestServerContext, setupTestServerHolder } from "../testHelpers/apolloTestServerHelpers";
import { generateMockRoutes, generateMockShuttles, generateMockStops } from "../testHelpers/mockDataGenerators";
import {
addMockRouteToRepository,
@@ -11,6 +11,7 @@ import { ISystem } from "../../src/entities/entities";
import assert = require("node:assert");
describe("SystemResolvers", () => {
const holder = setupTestServerHolder();
const context = setupTestServerContext();
let mockSystem: ISystem;
@@ -21,7 +22,7 @@ describe("SystemResolvers", () => {
// TODO: Consolidate these into one single method taking an object
async function getResponseFromQueryNeedingSystemId(query: string) {
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -102,7 +103,7 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -169,7 +170,7 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -237,7 +238,7 @@ describe("SystemResolvers", () => {
}
`;
return await context.testServer.executeOperation({
return await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
@@ -318,4 +319,4 @@ describe("SystemResolvers", () => {
expect(shuttles.length === expectedShuttles.length);
});
});
});
});

View File

@@ -4,6 +4,7 @@ import { MergedResolvers } from "../../src/MergedResolvers";
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import { beforeEach } from "@jest/globals";
import { ServerContext } from "../../src/ServerContext";
import { NotificationService } from "../../src/services/NotificationService";
function setUpTestServer() {
@@ -16,15 +17,33 @@ function setUpTestServer() {
});
}
/**
* Returns a `ServerContext` object which can be passed to requests
* for testing.
*/
export function setupTestServerContext() {
// @ts-ignore
const context: { testServer: ApolloServer<ServerContext>; repository: UnoptimizedInMemoryRepository } = {};
const context: { [key: string] : any } = {};
beforeEach(() => {
context.testServer = setUpTestServer();
context.repository = new UnoptimizedInMemoryRepository();
context.notificationService = new NotificationService(context.repository);
});
// Return a reference, not destructured values
return context;
}
return context as ServerContext;
}
/**
* Returns an object which holds a test server.
* This server is reset before every test.
* Tests should keep a reference to the holder object,
* and not destructure it.
*/
export function setupTestServerHolder() {
const holder: { [key: string]: any } = {};
beforeEach(() => {
holder.testServer = setUpTestServer();
});
return holder as { testServer: ApolloServer };
}

View File

@@ -1,4 +1,3 @@
import { UnoptimizedInMemoryRepository } from "../../src/repositories/UnoptimizedInMemoryRepository";
import {
generateMockEtas,
generateMockRoutes,
@@ -6,8 +5,9 @@ import {
generateMockStops,
generateMockSystems
} from "./mockDataGenerators";
import { GetterSetterRepository } from "../../src/repositories/GetterSetterRepository";
export async function addMockSystemToRepository(repository: UnoptimizedInMemoryRepository) {
export async function addMockSystemToRepository(repository: GetterSetterRepository) {
const mockSystems = generateMockSystems();
const mockSystem = mockSystems[0];
mockSystem.id = "1";
@@ -16,7 +16,7 @@ export async function addMockSystemToRepository(repository: UnoptimizedInMemoryR
return mockSystem;
}
export async function addMockRouteToRepository(repository: UnoptimizedInMemoryRepository, systemId: string) {
export async function addMockRouteToRepository(repository: GetterSetterRepository, systemId: string) {
const mockRoutes = generateMockRoutes();
const mockRoute = mockRoutes[0];
mockRoute.systemId = systemId;
@@ -25,7 +25,7 @@ export async function addMockRouteToRepository(repository: UnoptimizedInMemoryRe
return mockRoute;
}
export async function addMockStopToRepository(repository: UnoptimizedInMemoryRepository, systemId: string) {
export async function addMockStopToRepository(repository: GetterSetterRepository, systemId: string) {
const mockStops = generateMockStops();
const mockStop = mockStops[0];
mockStop.systemId = systemId;
@@ -34,7 +34,7 @@ export async function addMockStopToRepository(repository: UnoptimizedInMemoryRep
return mockStop;
}
export async function addMockShuttleToRepository(repository: UnoptimizedInMemoryRepository, systemId: string) {
export async function addMockShuttleToRepository(repository: GetterSetterRepository, systemId: string) {
const mockShuttles = generateMockShuttles();
const mockShuttle = mockShuttles[0];
mockShuttle.systemId = systemId;
@@ -42,7 +42,7 @@ export async function addMockShuttleToRepository(repository: UnoptimizedInMemory
return mockShuttle;
}
export async function addMockEtaToRepository(repository: UnoptimizedInMemoryRepository, stopId: string, shuttleId: string) {
export async function addMockEtaToRepository(repository: GetterSetterRepository, stopId: string, shuttleId: string) {
const etas = generateMockEtas();
const expectedEta = etas[0];
expectedEta.stopId = stopId;