Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ inferred, conflicting, and fixture states instead of presenting guesses as facts

```text
Foldkit web -> TypeBox HTTP contract -> Elysia transport -> Effect service
-> NTNU search/detail
-> NTNU search/detail/schedule
-> DBH grades + exam activity
```

Expand All @@ -30,6 +30,8 @@ Implemented now:
visible or shortlisted courses;
- a local saved list with labelled collections and comparison of two to four
courses;
- a weekly timetable for saved courses, with URL-backed ISO-week selection,
dated NTNU activities, source freshness, and named provider limitations;
- ordinary-term, bounded grade aggregation with pass/fail outcomes kept
separate from ordinal letter grades;
- official DBH exam registrations, attendance, pass, failure, and repeat-pass
Expand Down
145 changes: 145 additions & 0 deletions apps/course-api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ const unavailableSearchService: CourseDecisionService = {
),
};

const unavailableScheduleService: CourseDecisionService = {
...fixtureCourseDecisionService,
getSchedule: () =>
Effect.fail(
new CourseSourcesUnavailableError({
operation: 'schedule',
message: 'The NTNU schedule source is unavailable.',
}),
),
};

describe('course decision HTTP transport', () => {
it.each([
{
Expand Down Expand Up @@ -78,6 +89,126 @@ describe('course decision HTTP transport', () => {
expect(body).toMatchObject(expected);
});

it('returns a contract-valid schedule response in the requested order', async () => {
const response = await app.handle(
new Request('http://localhost/v1/course-schedules', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
courseCodes: ['TDT4136', 'TDT4136-1'],
term: '2026-autumn',
week: 45,
}),
}),
);
const body: unknown = await response.json();

expect(response.status).toBe(200);
expect(response.headers.get('cache-control')).toBe(
'public, max-age=60, stale-while-revalidate=900',
);
expect(body).toEqual(
expect.objectContaining({
items: [
expect.objectContaining({
courseCode: 'TDT4136',
sourceStatus: expect.objectContaining({
provider: 'ntnu-course-schedule',
status: 'available',
}),
activityStreams: [
expect.objectContaining({
activityCode: 'TDT4136-LECTURE-01',
title: 'Introduction to search',
summary: 'Lecture',
}),
expect.objectContaining({
activityCode: 'TDT4136-EXERCISE-01',
title: 'Search exercise',
summary: 'Exercise session',
}),
],
occurrences: expect.arrayContaining([
expect.objectContaining({
courseCode: 'TDT4136',
evidence: expect.objectContaining({
provider: 'ntnu-course-schedule',
kind: 'fixture',
}),
}),
]),
}),
expect.objectContaining({
courseCode: 'TDT4136-1',
sourceStatus: expect.objectContaining({
provider: 'ntnu-course-schedule',
status: 'unavailable',
}),
activityStreams: [],
occurrences: [],
}),
],
meta: {
count: 2,
term: '2026-autumn',
week: 45,
timezone: 'Europe/Oslo',
limitations: {
activitySelection: 'all-published-activities',
activityGrouping: 'unavailable',
exceptionSemantics: 'provider-status-unverified',
},
},
}),
);
});

it('keeps a valid fixture schedule available when the selected week has no events', async () => {
const response = await app.handle(
new Request('http://localhost/v1/course-schedules', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 44 }),
}),
);
const body: unknown = await response.json();

expect(response.status).toBe(200);
expect(body).toMatchObject({
items: [
{
courseCode: 'TDT4136',
sourceStatus: { status: 'available' },
occurrences: [],
activityStreams: [
expect.objectContaining({ activityCode: 'TDT4136-LECTURE-01' }),
expect.objectContaining({ activityCode: 'TDT4136-EXERCISE-01' }),
],
},
],
meta: { week: 44 },
});
});

it('returns a contract-valid problem when the schedule operation fails', async () => {
const unavailableApp = createCourseApi(unavailableScheduleService, () => 'request-test');
const response = await unavailableApp.handle(
new Request('http://localhost/v1/course-schedules', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 45 }),
}),
);
const body: unknown = await response.json();

expect(response.status).toBe(503);
expect(body).toMatchObject({
type: 'https://course-data.example/problems/course-schedule-unavailable',
status: 503,
requestId: 'request-test',
});
});

it.each([
{
name: 'invalid catalogue paging',
Expand All @@ -92,6 +223,15 @@ describe('course decision HTTP transport', () => {
body: JSON.stringify({ courseCodes: [] }),
}),
},
{
name: 'an invalid schedule week',
request: () =>
new Request('http://localhost/v1/course-schedules', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseCodes: ['TDT4136'], term: '2026-autumn', week: 0 }),
}),
},
])('returns a declared 400 problem for $name', async ({ request }) => {
const response = await app.handle(request());
const body = await response.json();
Expand Down Expand Up @@ -153,6 +293,11 @@ describe('course decision HTTP transport', () => {
method: 'post',
statuses: ['200', '400', '503'],
},
{
path: '/v1/course-schedules',
method: 'post',
statuses: ['200', '400', '503'],
},
{
path: '/v1/courses/{courseCode}/insight',
method: 'get',
Expand Down
63 changes: 61 additions & 2 deletions apps/course-api/src/app.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import {
CourseDecisionSignalsRequestDto,
CourseDecisionSignalsResponseDto,
CourseGradeSummariesRequestDto,
CourseGradeSummariesResponseDto,
CourseInsightParamsDto,
CourseInsightQueryDto,
CourseInsightResponseDto,
CourseGradeSummariesRequestDto,
CourseGradeSummariesResponseDto,
CourseScheduleRequestDto,
CourseScheduleResponseDto,
CourseSearchQueryDto,
CourseSearchResponseDto,
ProblemDto,
Expand All @@ -25,6 +27,7 @@ import {
toCourseDecisionSignalsDto,
toCourseGradeSummaryDto,
toCourseInsightDto,
toCourseScheduleResponseDto,
toCourseSearchItemDto,
} from './http/course-dto';

Expand Down Expand Up @@ -86,6 +89,7 @@ export const createCourseApi = (
search: '/v1/course-search',
gradeSummaries: '/v1/course-grade-summaries',
decisionSignals: '/v1/course-decision-signals',
schedule: '/v1/course-schedules',
insight: '/v1/courses/:courseCode/insight',
openapi: '/openapi',
openapiJson: '/openapi/json',
Expand All @@ -101,6 +105,7 @@ export const createCourseApi = (
gradeSummaries: t.String(),
decisionSignals: t.String(),
insight: t.String(),
schedule: t.String(),
openapi: t.String(),
openapiJson: t.String(),
}),
Expand Down Expand Up @@ -247,6 +252,60 @@ export const createCourseApi = (
},
},
)
.post(
'/v1/course-schedules',
async ({ body, request, set, status }) => {
const requestId = request.headers.get('cf-ray') ?? makeRequestId();
const result = await Effect.runPromise(
Effect.result(
service.getSchedule({
courseCodes: body.courseCodes,
term: body.term,
week: body.week,
}),
),
);
set.headers['x-request-id'] = requestId;
set.headers['cache-control'] = 'public, max-age=60, stale-while-revalidate=900';

if (Result.isFailure(result)) {
if (result.failure._tag === 'CourseInvalidTermError') {
return status(
400,
problem(
requestId,
400,
'invalid-course-term',
'Invalid course term',
result.failure.message,
),
);
}
return status(
503,
problem(
requestId,
503,
'course-schedule-unavailable',
'Course schedule unavailable',
result.failure.message,
),
);
}

return toCourseScheduleResponseDto(result.success);
},
{
body: CourseScheduleRequestDto,
response: { 200: CourseScheduleResponseDto, 400: ProblemDto, 503: ProblemDto },
detail: {
summary: 'Get published NTNU course schedule occurrences for one week',
description:
'Returns published activities in the requested Oslo-local ISO week with explicit source availability.',
tags: ['Courses'],
},
},
)
.post(
'/v1/course-grade-summaries',
async ({ body, request, set, status }) => {
Expand Down
Loading
Loading