Merge pull request #83 from brendan-ch/chore/sort-resolver-data

chore/sort-resolver-data
This commit is contained in:
2025-09-30 15:33:59 -07:00
committed by GitHub
14 changed files with 364 additions and 120 deletions

102
AGENTS.md Normal file
View File

@@ -0,0 +1,102 @@
# AGENTS.md
This file provides guidance to coding agents (e.g., Codex CLI, Claude Code, and other AI coding assistants) when working with code in this repository.
## Development Commands
### Core Development
```bash
# Start development server with hot reloading
docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development (install, codegen, tsc)
npm run build:dev
```
### Testing
```bash
# Run all tests via npm
npm test
# Run specific test file
npm test -- --testPathPattern=<test-file-name>
# Run tests with coverage
npm test -- --coverage
```
## Architecture Overview
Project Inter Server is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
- InterchangeSystem: Central orchestrator for shuttles, parking, and notifications
- Use `InterchangeSystem.build()` in production
- Use `InterchangeSystem.buildForTesting()` in tests
- Repository Pattern (data access abstraction)
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
- Parking: `InMemoryParkingRepository`
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
- Data Loaders (external API integration)
- `ApiBasedShuttleRepositoryLoader` Passio GO! API
- `ChapmanApiBasedParkingRepositoryLoader` Parking data
- `TimedApiBasedRepositoryLoader` Periodic refresh wrapper
- Notification System
- `ETANotificationScheduler` Shuttle arrival notifications
- `AppleNotificationSender` APNS integration
## GraphQL
- Schema definition: `schema.graphqls`
- Generated types: `src/generated/`
- Resolvers: `src/resolvers/`
- Resolver merge: `src/MergedResolvers.ts`
## Directory Structure
- `src/entities/` Core business logic
- `src/repositories/` Data access layer
- `src/loaders/` External API integrations
- `src/notifications/` Push notification system
- `src/resolvers/` GraphQL resolvers and tests
- `testHelpers/` Test utilities and mock data
## Docker Services
- `dev` Development server with hot reload
- `test` Unit/integration tests
- `redis` Persistent Redis
- `redis-no-persistence` Ephemeral Redis for tests
## Testing Patterns
- Prefer `buildForTesting()` to construct systems in tests.
- Mock external APIs using JSON snapshots under `testHelpers/jsonSnapshots`.
- Use in-memory repositories for speed where possible.
- When adding features that affect API output, add focused resolver tests in the corresponding `src/resolvers/__tests__` file.
- Separate unit tests from integration tests where practical to keep feedback fast and failures well-scoped.
## Development Guidelines
### General Guidelines
- Use test-driven development where possible. Write tests before implementation and run them before and after changes.
- Use Docker Compose for tests. Run `docker compose run test` so you can see full output.
### Git Workflow
- Name pull requests after their branch name.
### Code Style
- Prefer arrow functions, especially within classes.
- Keep changes minimal and focused; avoid unrelated refactors. Mention incidental issues separately.
- Respect existing interfaces and types; use non-destructive edits.
### Agent Tips
- Write or update tests alongside changes; validate with `docker compose run test`.
- When touching GraphQL resolvers, co-locate new tests in `src/resolvers/__tests__`.
## Multi-tenant Support
Currently supports Chapman University (Passio System ID: `263`). Each university system uses isolated repositories and configuration. New systems should be added via `InterchangeSystem` configuration and appropriate loaders.

101
CLAUDE.md
View File

@@ -1,101 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Development Commands
### Core Development
```bash
# Start development server with hot reloading
docker compose run dev
# Run comprehensive test suite
docker compose run test
# Generate GraphQL TypeScript types
npm run generate
# Build for development
npm run build:dev
```
### Testing
```bash
# Run all tests
npm test
# Run specific test file
npm test -- --testPathPattern=<test-file-name>
# Run tests with coverage
npm test -- --coverage
```
## Architecture Overview
**Project Inter Server** is a GraphQL-based backend for college transit tracking with real-time shuttle data, parking availability, and push notifications.
### Core Components
1. **InterchangeSystem** - Central orchestrator managing shuttles, parking, and notifications
- Use `InterchangeSystem.build()` for production
- Use `InterchangeSystem.buildForTesting()` for tests
2. **Repository Pattern** - Data access abstraction
- Shuttle: `UnoptimizedInMemoryShuttleRepository`
- Parking: `InMemoryParkingRepository`
- Notifications: `RedisNotificationRepository` (prod) / `InMemoryNotificationRepository` (test)
3. **Data Loaders** - External API integration
- `ApiBasedShuttleRepositoryLoader` - Passio GO! API integration
- `ChapmanApiBasedParkingRepositoryLoader` - University parking data
- `TimedApiBasedRepositoryLoader` - Periodic data refresh
4. **Notification System**
- `ETANotificationScheduler` - Manages shuttle arrival notifications
- `AppleNotificationSender` - APNS integration
- Default threshold: 180 seconds
### GraphQL Schema
- Schema definition: `schema.graphqls`
- Generated types: `src/generated/`
- Resolvers: `src/resolvers/`
- Combined in: `src/MergedResolvers.ts`
### Directory Structure
- `src/entities/` - Core business logic
- `src/repositories/` - Data access layer
- `src/loaders/` - External API integrations
- `src/notifications/` - Push notification system
- `test/` - Comprehensive test suite with mock data
### Multi-tenant Support
Currently supports Chapman University (Passio System ID: "263"). Each university system has:
- System-specific configurations
- Isolated data repositories
- Custom API integrations
### Docker Services
- `dev` - Development with hot reload
- `test` - Unit/integration testing
- `redis` - Persistent Redis
- `redis-no-persistence` - Ephemeral Redis for tests
### Testing Patterns
- Use `buildForTesting()` for InterchangeSystem in tests
- Mock external APIs with JSON snapshots in test data
- Separate unit tests from integration tests
- Use in-memory repositories for faster testing
## Development Guidelines
### General Guidelines
- Use test-driven development. Always write tests before implementation, and run them before and after implementation.
- Use Docker Compose for tests. Make sure you run it in a way where you can actually see the test result.
### Git Workflow
- Use the name of the branch for all pull requests
### Code Style
- Prefer arrow functions, especially in classes

View File

@@ -12,7 +12,10 @@ export const ParkingSystemResolvers: Resolvers<ServerContext> = {
if (!parkingRepository) return [];
const parkingStructures = await parkingRepository.getParkingStructures();
return parkingStructures.map((structure) => {
return parkingStructures
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((structure) => {
return {
...structure,
systemId: parent.systemId

View File

@@ -7,12 +7,15 @@ const GRAPHQL_SCHEMA_CURRENT_VERSION = 1;
export const QueryResolvers: Resolvers<ServerContext> = {
Query: {
systems: async (_parent, args, contextValue, _info) => {
return contextValue.systems.map((system) => {
return {
name: system.name,
id: system.id,
};
})
return contextValue.systems
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((system) => {
return {
name: system.name,
id: system.id,
};
})
},
system: async (_parent, args, contextValue, _info) => {
if (!args.id) return null;

View File

@@ -9,7 +9,10 @@ export const RouteResolvers: Resolvers<ServerContext> = {
const shuttles = await system.shuttleRepository.getShuttlesByRouteId(parent.id);
return shuttles.map(({
return shuttles
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map(({
coordinates,
name,
id,
@@ -51,7 +54,10 @@ export const RouteResolvers: Resolvers<ServerContext> = {
if (!system) return null;
const orderedStops = await system.shuttleRepository.getOrderedStopsByRouteId(parent.id);
return orderedStops.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
return orderedStops
.slice()
.sort((a, b) => a.position - b.position)
.map(({ routeId, stopId, position, systemId, updatedTime }) => ({
routeId,
stopId,
position,

View File

@@ -3,7 +3,7 @@ import { ServerContext } from "../ServerContext";
export const ShuttleResolvers: Resolvers<ServerContext> = {
Shuttle: {
eta: async (parent, args, contextValue, info) => {
eta: async (parent, args, contextValue, _) => {
if (!args.forStopId) return null;
const system = contextValue.findSystemById(parent.systemId);
@@ -21,7 +21,7 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
updatedTimeMs: etaForStopId.updatedTime,
};
},
etas: async (parent, args, contextValue, info) => {
etas: async (parent, args, contextValue, _) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;
@@ -45,12 +45,14 @@ export const ShuttleResolvers: Resolvers<ServerContext> = {
}));
if (computedEtas.every((eta) => eta !== null)) {
return computedEtas;
return computedEtas
.slice()
.sort((a, b) => (a!.secondsRemaining - b!.secondsRemaining));
}
return [];
},
route: async (parent, args, contextValue, info) => {
route: async (parent, args, contextValue, _) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) return null;

View File

@@ -8,14 +8,16 @@ export const StopResolvers: Resolvers<ServerContext> = {
if (!system) {
return [];
}
return await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
const orderedStops = await system.shuttleRepository.getOrderedStopsByStopId(parent.id);
return orderedStops.slice().sort((a, b) => a.position - b.position);
},
etas: async (parent, args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.systemId);
if (!system) {
return [];
}
return await system.shuttleRepository.getEtasForStopId(parent.id);
const etas = await system.shuttleRepository.getEtasForStopId(parent.id);
return etas.slice().sort((a, b) => a.secondsRemaining - b.secondsRemaining);
},
},
}

View File

@@ -9,7 +9,8 @@ export const SystemResolvers: Resolvers<ServerContext> = {
return [];
}
return await system.shuttleRepository.getRoutes();
const routes = await system.shuttleRepository.getRoutes();
return routes.slice().sort((a, b) => a.name.localeCompare(b.name));
},
stops: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);
@@ -17,7 +18,8 @@ export const SystemResolvers: Resolvers<ServerContext> = {
return [];
}
return await system.shuttleRepository.getStops();
const stops = await system.shuttleRepository.getStops();
return stops.slice().sort((a, b) => a.name.localeCompare(b.name));
},
stop: async (parent, args, contextValue, _info) => {
if (!args.id) return null;
@@ -78,7 +80,8 @@ export const SystemResolvers: Resolvers<ServerContext> = {
return [];
}
return await system.shuttleRepository.getShuttles();
const shuttles = await system.shuttleRepository.getShuttles();
return shuttles.slice().sort((a, b) => a.name.localeCompare(b.name));
},
parkingSystem: async (parent, _args, contextValue, _info) => {
const system = contextValue.findSystemById(parent.id);

View File

@@ -81,6 +81,49 @@ describe("ParkingSystemResolver", () => {
expect(parkingStructures).toHaveLength(0);
});
it("returns parking structures sorted by name", async () => {
// Create two out-of-order structures by name
const s1 = {
id: "ps1",
name: "Barrera",
capacity: 100,
spotsAvailable: 50,
coordinates: { latitude: 0, longitude: 0 },
address: "1 Anywhere",
updatedTime: new Date(),
};
const s2 = {
id: "ps2",
name: "Anderson",
capacity: 100,
spotsAvailable: 50,
coordinates: { latitude: 0, longitude: 0 },
address: "2 Anywhere",
updatedTime: new Date(),
};
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(s1);
await context.systems[0].parkingRepository?.addOrUpdateParkingStructure(s2);
const minimalQuery = `
query GetParkingStructuresBySystem($systemId: ID!) {
system(id: $systemId) {
parkingSystem {
parkingStructures {
name
}
}
}
}
`;
const response = await getResponseFromQueryNeedingSystemId(minimalQuery);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.parkingSystem.parkingStructures.map((p: any) => p.name);
expect(names).toEqual([s2.name, s1.name]);
});
});

View File

@@ -40,6 +40,41 @@ describe("QueryResolvers", () => {
expect(response.body.singleResult.data?.systems).toHaveLength(systems.length);
});
it("returns systems sorted by name", async () => {
const s1 = buildSystemForTesting();
const s2 = buildSystemForTesting();
const s3 = buildSystemForTesting();
s1.name = "Chapman University";
s1.id = "a";
s2.name = "City of Monterey";
s2.id = "b";
s3.name = "Cal State Long Beach";
s3.id = "c";
context.systems = [s1, s2, s3];
const query = `
query GetSystems
{
systems {
name
}
}
`;
const response = await holder.testServer.executeOperation({
query,
}, {
contextValue: context
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).systems.map((s: any) => s.name);
expect(names).toEqual(["Cal State Long Beach", "Chapman University", "City of Monterey"]);
});
});
describe("system", () => {

View File

@@ -79,6 +79,27 @@ describe("RouteResolvers", () => {
any).system.route.shuttles;
expect(shuttles.length).toEqual(0);
});
it("returns shuttles sorted by name", async () => {
const shuttles = generateMockShuttles();
// use two shuttles for determinism
const s1 = { ...shuttles[0], name: "Zed" };
const s2 = { ...shuttles[1], name: "Alpha" };
s1.systemId = mockSystem.id;
s1.routeId = mockRoute.id;
s2.systemId = mockSystem.id;
s2.routeId = mockRoute.id;
await context.systems[0].shuttleRepository.addOrUpdateShuttle(s1);
await context.systems[0].shuttleRepository.addOrUpdateShuttle(s2);
const response = await getResponseForShuttlesQuery();
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined()
const names = (response.body.singleResult.data as any).system.route.shuttles.map((s: any) => s.name);
expect(names).toEqual(["Alpha", "Zed"]);
});
});
describe("orderedStop", () => {
@@ -199,5 +220,22 @@ describe("RouteResolvers", () => {
const retrievedOrderedStops = (response.body.singleResult.data as any).system.route.orderedStops;
expect(retrievedOrderedStops).toHaveLength(0);
});
it("returns ordered stops sorted by position", async () => {
const stops = generateMockOrderedStops().slice(0, 3).map((s) => ({ ...s }));
// Force same routeId and distinct positions out of order
stops[0].routeId = mockRoute.id; stops[0].position = 3; stops[0].stopId = "stA";
stops[1].routeId = mockRoute.id; stops[1].position = 1; stops[1].stopId = "stB";
stops[2].routeId = mockRoute.id; stops[2].position = 2; stops[2].stopId = "stC";
await Promise.all(stops.map(s => context.systems[0].shuttleRepository.addOrUpdateOrderedStop(s)));
const response = await getResponseForOrderedStopsQuery();
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const stopIds = (response.body.singleResult.data as any).system.route.orderedStops.map((s: any) => s.stopId);
const expectedOrder = [...stops].sort((a, b) => a.position - b.position).map(s => s.stopId);
expect(stopIds).toEqual(expectedOrder);
});
});
});

View File

@@ -141,6 +141,31 @@ describe("ShuttleResolvers", () => {
any).system.shuttle.etas).toHaveLength(0);
});
it("returns ETAs sorted by secondsRemaining", async () => {
const e1 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stA", secondsRemaining: 300 };
const e2 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stB", secondsRemaining: 30 };
const e3 = { ...generateMockEtas()[0], shuttleId: mockShuttle.id, stopId: "stC", secondsRemaining: 120 };
await context.systems[0].shuttleRepository.addOrUpdateEta(e1);
await context.systems[0].shuttleRepository.addOrUpdateEta(e2);
await context.systems[0].shuttleRepository.addOrUpdateEta(e3);
const response = await holder.testServer.executeOperation({
query,
variables: {
systemId: mockSystem.id,
shuttleId: mockShuttle.id,
},
}, {
contextValue: context
});
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const seconds = (response.body.singleResult.data as any).system.shuttle.etas.map((e: any) => e.secondsRemaining);
expect(seconds).toEqual([30, 120, 300]);
});
});
describe("route", () => {
const query = `

View File

@@ -67,6 +67,25 @@ describe("StopResolvers", () => {
expect(response.body.singleResult.errors).toBeUndefined();
expect((response.body.singleResult.data as any).system.stop.orderedStops).toHaveLength(0);
});
it("returns ordered stops sorted by position", async () => {
// Create three ordered stops with out-of-order positions and distinct routeIds
const base = generateMockOrderedStops()[0];
const o1 = { ...base, stopId: mockStop.id, routeId: "rA", position: 3 };
const o2 = { ...base, stopId: mockStop.id, routeId: "rB", position: 1 };
const o3 = { ...base, stopId: mockStop.id, routeId: "rC", position: 2 };
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o1);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o2);
await context.systems[0].shuttleRepository.addOrUpdateOrderedStop(o3);
const response = await getResponseForQuery(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const routeIds = (response.body.singleResult.data as any).system.stop.orderedStops.map((s: any) => s.routeId);
const expectedOrder = [o2, o3, o1].map(s => s.routeId);
expect(routeIds).toEqual(expectedOrder);
});
});
describe("etas", () => {
@@ -104,5 +123,21 @@ describe("StopResolvers", () => {
expect(response.body.singleResult.errors).toBeUndefined();
expect((response.body.singleResult.data as any).system.stop.etas).toHaveLength(0);
});
it("returns ETAs sorted by secondsRemaining", async () => {
const e1 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shA", secondsRemaining: 240 };
const e2 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shB", secondsRemaining: 60 };
const e3 = { ...generateMockEtas()[0], stopId: mockStop.id, shuttleId: "shC", secondsRemaining: 120 };
await context.systems[0].shuttleRepository.addOrUpdateEta(e1);
await context.systems[0].shuttleRepository.addOrUpdateEta(e2);
await context.systems[0].shuttleRepository.addOrUpdateEta(e3);
const response = await getResponseForQuery(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const seconds = (response.body.singleResult.data as any).system.stop.etas.map((e: any) => e.secondsRemaining);
expect(seconds).toEqual([60, 120, 240]);
});
});
});

View File

@@ -4,7 +4,6 @@ import {
generateMockRoutes,
generateMockShuttles,
generateMockStops,
generateParkingStructures
} from "../../../testHelpers/mockDataGenerators";
import {
addMockRouteToRepository,
@@ -62,6 +61,23 @@ describe("SystemResolvers", () => {
const routes = (response.body.singleResult.data as any).system.routes;
expect(routes.length === expectedRoutes.length);
});
it("returns routes sorted by name", async () => {
const routes = generateMockRoutes();
// Insert in reverse name order to verify sorting
const reversed = [...routes].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (route) => {
route.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateRoute(route);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.routes.map((r: any) => r.name);
expect(names).toEqual([...routes].sort((a, b) => a.name.localeCompare(b.name)).map(r => r.name));
});
});
describe("stops", () => {
@@ -90,6 +106,22 @@ describe("SystemResolvers", () => {
const stops = (response.body.singleResult.data as any).system.stops;
expect(stops.length === expectedStops.length);
});
it("returns stops sorted by name", async () => {
const stops = generateMockStops();
const reversed = [...stops].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (stop) => {
stop.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateStop(stop);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.stops.map((s: any) => s.name);
expect(names).toEqual([...stops].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
});
});
describe("stop", () => {
@@ -312,6 +344,22 @@ describe("SystemResolvers", () => {
const shuttles = (response.body.singleResult.data as any).system.shuttles;
expect(shuttles.length === expectedShuttles.length);
});
it("returns shuttles sorted by name", async () => {
const shuttles = generateMockShuttles();
const reversed = [...shuttles].sort((a, b) => b.name.localeCompare(a.name));
await Promise.all(reversed.map(async (shuttle) => {
shuttle.systemId = mockSystem.id;
await context.systems[0].shuttleRepository.addOrUpdateShuttle(shuttle);
}));
const response = await getResponseFromQueryNeedingSystemId(query);
assert(response.body.kind === "single");
expect(response.body.singleResult.errors).toBeUndefined();
const names = (response.body.singleResult.data as any).system.shuttles.map((s: any) => s.name);
expect(names).toEqual([...shuttles].sort((a, b) => a.name.localeCompare(b.name)).map(s => s.name));
});
});
});