mirror of
https://github.com/UniversityOfHelsinkiCS/oodikone.git
synced 2026-10-12 19:03:39 +03:00
[UnitTests][Updater] Add basic unit tests to updater
This commit is contained in:
committed by
Aaro Koivukangas
parent
3a1bee9b79
commit
f5229ee678
+1161
-2
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,10 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./src/index.js",
|
"start": "node ./src/index.js",
|
||||||
"dev": "nodemon ./src/index.js"
|
"dev": "nodemon ./src/index.js",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:unit": "vitest run ./tests/unit",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"async": "^3.2.6",
|
"async": "^3.2.6",
|
||||||
@@ -21,6 +24,7 @@
|
|||||||
"winston-transport-sentry-node": "^3.0.0"
|
"winston-transport-sentry-node": "^3.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "3.1.14"
|
"nodemon": "3.1.14",
|
||||||
|
"vitest": "4.1.9"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ import {
|
|||||||
isModule,
|
isModule,
|
||||||
} from '../mapper.js'
|
} from '../mapper.js'
|
||||||
|
|
||||||
|
const earlierOf = (a, b) => (new Date(a) < new Date(b) ? a : b)
|
||||||
|
const laterOf = (a, b) => (new Date(a) > new Date(b) ? a : b)
|
||||||
|
|
||||||
const updateTeachers = async attainments => {
|
const updateTeachers = async attainments => {
|
||||||
const acceptorPersonIds = flatten(
|
const acceptorPersonIds = flatten(
|
||||||
attainments.map(attainment =>
|
attainments.map(attainment =>
|
||||||
@@ -184,6 +187,18 @@ export const updateAttainments = async (
|
|||||||
coursecode: parsedCourseCode,
|
coursecode: parsedCourseCode,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
} else {
|
||||||
|
// If the course exists, update it
|
||||||
|
coursesToBeCreated.set(parsedCourseCode, {
|
||||||
|
...course,
|
||||||
|
groupId: course.groupId ?? course.id,
|
||||||
|
name: att.name,
|
||||||
|
isPrimary: course.isPrimary ?? true,
|
||||||
|
coursetypecode: att.study_level_urn,
|
||||||
|
courseUnitType: att.course_unit_type_urn,
|
||||||
|
minAttainmentDate: earlierOf(course.minAttainmentDate, att.attainment_date),
|
||||||
|
maxAttainmentDate: laterOf(course.maxAttainmentDate, att.attainment_date),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const courseIdToUse = attIdToCourseCode[att.id]
|
const courseIdToUse = attIdToCourseCode[att.id]
|
||||||
@@ -204,7 +219,6 @@ export const updateAttainments = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
courseUnit = course || { id: courseIdToUse, code: courseIdToUse }
|
courseUnit = course || { id: courseIdToUse, code: courseIdToUse }
|
||||||
courseUnit.group_id = courseUnit.id
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the CU to the mapping objects for creditMapper to work properly.
|
// Add the CU to the mapping objects for creditMapper to work properly.
|
||||||
@@ -230,6 +244,7 @@ export const updateAttainments = async (
|
|||||||
const attainmentIdCourseCodeMapForCustomCourseUnitAttainments = attainments.reduce(findMissingCourseCodes, {})
|
const attainmentIdCourseCodeMapForCustomCourseUnitAttainments = attainments.reduce(findMissingCourseCodes, {})
|
||||||
const missingCodes = Object.values(attainmentIdCourseCodeMapForCustomCourseUnitAttainments)
|
const missingCodes = Object.values(attainmentIdCourseCodeMapForCustomCourseUnitAttainments)
|
||||||
const courses = await selectFromByIds('course_units', missingCodes, 'code')
|
const courses = await selectFromByIds('course_units', missingCodes, 'code')
|
||||||
|
|
||||||
return await Promise.all(
|
return await Promise.all(
|
||||||
attainments.map(
|
attainments.map(
|
||||||
addCourseUnitToCustomCourseUnitAttainments(courses, attainmentIdCourseCodeMapForCustomCourseUnitAttainments)
|
addCourseUnitToCustomCourseUnitAttainments(courses, attainmentIdCourseCodeMapForCustomCourseUnitAttainments)
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
// updateAttainments talks directly to knex, Sequelize models, and the db/index.js helpers. This
|
||||||
|
// establishes the pattern for testing this kind of DB-coupled orchestrator: mock the DB layer at
|
||||||
|
// its boundary and let the real (pure) mapping logic run on top of canned data.
|
||||||
|
//
|
||||||
|
// vi.hoisted is required because vi.mock factories run before the rest of this file, so any mock
|
||||||
|
// state they reference (and that assertions below need to inspect) has to be created up front.
|
||||||
|
const { knexMock, bulkCreateMock, selectFromByIdsMock, selectOneByIdMock } = vi.hoisted(() => {
|
||||||
|
const makeChainableQuery = result => {
|
||||||
|
const builder = {
|
||||||
|
select: () => builder,
|
||||||
|
from: () => builder,
|
||||||
|
where: () => builder,
|
||||||
|
whereIn: () => builder,
|
||||||
|
then: (resolve, reject) => Promise.resolve(result).then(resolve, reject),
|
||||||
|
}
|
||||||
|
return builder
|
||||||
|
}
|
||||||
|
|
||||||
|
const tableResults = {
|
||||||
|
course_units: [{ id: 'cu1', code: 'TKT10' }],
|
||||||
|
modules: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const knexMock = Object.assign(
|
||||||
|
vi.fn(table => makeChainableQuery(tableResults[table] ?? [])),
|
||||||
|
{ select: () => makeChainableQuery([]) }
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
knexMock,
|
||||||
|
bulkCreateMock: vi.fn(),
|
||||||
|
selectFromByIdsMock: vi.fn().mockResolvedValue([]),
|
||||||
|
selectOneByIdMock: vi.fn().mockResolvedValue(null),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/db/connection.js', () => ({ dbConnections: { knex: knexMock } }))
|
||||||
|
|
||||||
|
vi.mock('@/db/index.js', () => ({
|
||||||
|
selectFromByIds: selectFromByIdsMock,
|
||||||
|
selectOneById: selectOneByIdMock,
|
||||||
|
bulkCreate: bulkCreateMock,
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/db/models/index.js', () => ({
|
||||||
|
Course: { name: 'Course', findOne: vi.fn() },
|
||||||
|
Teacher: { name: 'Teacher', findOne: vi.fn() },
|
||||||
|
Credit: { name: 'Credit' },
|
||||||
|
CreditTeacher: { name: 'CreditTeacher' },
|
||||||
|
CourseProvider: { name: 'CourseProvider', findOne: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/utils/logger.js', () => ({
|
||||||
|
default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
// updateAttainments builds its credit rows via mapper.js's real creditMapper, which in turn reads
|
||||||
|
// from shared.js's Redis-backed lookup maps - mocked here the same way as in mapper.test.js.
|
||||||
|
vi.mock('@/updater/shared.js', () => ({
|
||||||
|
getSemesterByDate: vi.fn(() => ({ semestercode: 100, composite: '2020-2021-fall' })),
|
||||||
|
getGrade: vi.fn(() => ({ value: '5', passed: true })),
|
||||||
|
getUniOrgId: vi.fn(() => 'uni-org1'),
|
||||||
|
getCountry: vi.fn(),
|
||||||
|
getCreditTypeCodeFromAttainment: vi.fn(() => 'PASSED'),
|
||||||
|
educationTypeToExtentcode: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { Course } from '@/db/models/index.js'
|
||||||
|
import { updateAttainments } from '@/updater/updateStudents/attainments.js'
|
||||||
|
|
||||||
|
describe('updateAttainments', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
bulkCreateMock.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Get what calls were directed to the wanted model by bulkCreate */
|
||||||
|
const findBulkCreateCall = modelName => bulkCreateMock.mock.calls.find(([model]) => model.name === modelName)
|
||||||
|
|
||||||
|
it('maps a plain course unit attainment into a Credit bulkCreate call', async () => {
|
||||||
|
const attainment = {
|
||||||
|
id: 'att1',
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
misregistration: false,
|
||||||
|
credits: 5,
|
||||||
|
person_id: 'p1',
|
||||||
|
registration_date: '2020-01-10',
|
||||||
|
grade_scale_id: 'scale1',
|
||||||
|
grade_id: 'grade1',
|
||||||
|
organisations: [{ organisationId: 'org1', roleUrn: 'urn:code:organisation-role:responsible-organisation' }],
|
||||||
|
attainment_date: '2020-01-15',
|
||||||
|
course_unit_id: 'cu1',
|
||||||
|
module_id: null,
|
||||||
|
module_group_id: null,
|
||||||
|
study_right_id: null,
|
||||||
|
attainment_language_urn: 'urn:code:language:fi',
|
||||||
|
acceptor_persons: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAttainments([attainment], { p1: '014123456' }, new Set(), {})
|
||||||
|
|
||||||
|
const [, credits] = findBulkCreateCall('Credit')
|
||||||
|
expect(credits).toHaveLength(1)
|
||||||
|
expect(credits[0]).toMatchObject({
|
||||||
|
id: 'att1',
|
||||||
|
student_studentnumber: '014123456',
|
||||||
|
course_id: 'cu1',
|
||||||
|
course_code: 'TKT10',
|
||||||
|
credits: 5,
|
||||||
|
is_open: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
// No custom-attainment repairs or acceptor persons were involved, so these come back empty.
|
||||||
|
expect(findBulkCreateCall('Teacher')[1]).toEqual([])
|
||||||
|
expect(findBulkCreateCall('Course')[1]).toEqual([])
|
||||||
|
expect(findBulkCreateCall('CreditTeacher')[1]).toEqual([])
|
||||||
|
expect(findBulkCreateCall('CourseProvider')[1]).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates new custom course unit from an attainment', async () => {
|
||||||
|
const attainment = {
|
||||||
|
id: 'att-custom-1',
|
||||||
|
type: 'CustomCourseUnitAttainment',
|
||||||
|
code: 'XYZ-123',
|
||||||
|
misregistration: false,
|
||||||
|
credits: 5,
|
||||||
|
person_id: 'p1',
|
||||||
|
registration_date: '2020-01-10',
|
||||||
|
grade_scale_id: 'scale1',
|
||||||
|
grade_id: 'grade1',
|
||||||
|
organisations: [{ organisationId: 'org1', roleUrn: 'urn:code:organisation-role:responsible-organisation' }],
|
||||||
|
attainment_date: '2020-01-15',
|
||||||
|
course_unit_id: null,
|
||||||
|
module_id: null,
|
||||||
|
module_group_id: null,
|
||||||
|
study_right_id: null,
|
||||||
|
attainment_language_urn: 'urn:code:language:fi',
|
||||||
|
acceptor_persons: [],
|
||||||
|
name: { fi: 'Testikurssi' },
|
||||||
|
study_level_urn: 'lvl1',
|
||||||
|
course_unit_type_urn: 'type1',
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAttainments([attainment], { p1: '014123456' }, new Set(), {})
|
||||||
|
|
||||||
|
const [, courses] = findBulkCreateCall('Course')
|
||||||
|
expect(courses).toHaveLength(1)
|
||||||
|
expect(courses[0].groupId).toBe(courses[0].id)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates custom course unit with fields from a new attainment', async () => {
|
||||||
|
Course.findOne.mockResolvedValueOnce({
|
||||||
|
id: 'XYZ-123',
|
||||||
|
groupId: null, // Group id missing
|
||||||
|
code: 'XYZ-123',
|
||||||
|
name: { fi: 'Vanha nimi' }, // Incorrect name
|
||||||
|
isStudyModule: false,
|
||||||
|
isPrimary: true,
|
||||||
|
coursetypecode: 'lvl1',
|
||||||
|
maxAttainmentDate: '2019-06-01',
|
||||||
|
minAttainmentDate: '2019-01-01',
|
||||||
|
substitutionGroups: [],
|
||||||
|
courseUnitType: 'type1',
|
||||||
|
})
|
||||||
|
|
||||||
|
const attainment = {
|
||||||
|
id: 'att-custom-2',
|
||||||
|
type: 'CustomCourseUnitAttainment',
|
||||||
|
code: 'XYZ-123',
|
||||||
|
misregistration: false,
|
||||||
|
credits: 5,
|
||||||
|
person_id: 'p1',
|
||||||
|
registration_date: '2020-01-10',
|
||||||
|
grade_scale_id: 'scale1',
|
||||||
|
grade_id: 'grade1',
|
||||||
|
organisations: [{ organisationId: 'org1', roleUrn: 'urn:code:organisation-role:responsible-organisation' }],
|
||||||
|
attainment_date: '2020-01-15',
|
||||||
|
course_unit_id: null,
|
||||||
|
module_id: null,
|
||||||
|
module_group_id: null,
|
||||||
|
study_right_id: null,
|
||||||
|
attainment_language_urn: 'urn:code:language:fi',
|
||||||
|
acceptor_persons: [],
|
||||||
|
name: { fi: 'Uusi nimi' },
|
||||||
|
study_level_urn: 'lvl1',
|
||||||
|
course_unit_type_urn: 'type1',
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateAttainments([attainment], { p1: '014123456' }, new Set(), {})
|
||||||
|
|
||||||
|
const [, courses] = findBulkCreateCall('Course')
|
||||||
|
expect(courses).toHaveLength(1)
|
||||||
|
expect(courses[0].id).toBe('XYZ-123')
|
||||||
|
expect(courses[0].groupId).toBe('XYZ-123')
|
||||||
|
expect(courses[0].name.fi).toBe('Uusi nimi')
|
||||||
|
expect(courses[0].maxAttainmentDate).toBe('2020-01-15')
|
||||||
|
expect(courses[0].minAttainmentDate).toBe('2019-01-01')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { courseProviderMapper } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('courseProviderMapper', () => {
|
||||||
|
it('maps organisation shares to a course provider row', () => {
|
||||||
|
const mapProvider = courseProviderMapper('CG1')
|
||||||
|
expect(mapProvider({ organisationId: 'org1', shares: 50 })).toEqual({
|
||||||
|
coursecode: 'CG1',
|
||||||
|
organizationcode: 'org1',
|
||||||
|
shares: 50,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { creditMapper } from '@/updater/mapper.js'
|
||||||
|
import { getCreditTypeCodeFromAttainment, getGrade, getSemesterByDate, getUniOrgId } from '@/updater/shared.js'
|
||||||
|
|
||||||
|
describe('creditMapper', () => {
|
||||||
|
const baseAttainment = {
|
||||||
|
id: 'att1',
|
||||||
|
credits: 5,
|
||||||
|
person_id: 'p1',
|
||||||
|
registration_date: '2020-01-10',
|
||||||
|
grade_scale_id: 'scale1',
|
||||||
|
grade_id: 'grade1',
|
||||||
|
organisations: [{ organisationId: 'org1', roleUrn: 'urn:code:organisation-role:responsible-organisation' }],
|
||||||
|
attainment_date: '2020-01-15',
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
course_unit_id: 'cu1',
|
||||||
|
module_id: null,
|
||||||
|
module_group_id: null,
|
||||||
|
study_right_id: null,
|
||||||
|
attainment_language_urn: 'urn:code:language:fi',
|
||||||
|
}
|
||||||
|
|
||||||
|
const lookupMaps = (overrides = {}) => ({
|
||||||
|
personIdToStudentNumber: { p1: '014123456' },
|
||||||
|
courseUnitIdToCourseCode: { cu1: 'TKT10' },
|
||||||
|
moduleGroupIdToModuleCode: {},
|
||||||
|
studyRightIdToEducationType: {},
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapCredit = maps =>
|
||||||
|
creditMapper(
|
||||||
|
maps.personIdToStudentNumber,
|
||||||
|
maps.courseUnitIdToCourseCode,
|
||||||
|
maps.moduleGroupIdToModuleCode,
|
||||||
|
maps.studyRightIdToEducationType
|
||||||
|
)
|
||||||
|
|
||||||
|
it('maps attainment correctly into credit', () => {
|
||||||
|
getUniOrgId.mockReturnValue('uni-org1')
|
||||||
|
getSemesterByDate.mockReturnValue({ semestercode: 100, composite: '2020-2021-fall' })
|
||||||
|
getGrade.mockReturnValue({ value: '5', passed: true })
|
||||||
|
getCreditTypeCodeFromAttainment.mockReturnValue('PASSED')
|
||||||
|
|
||||||
|
const result = mapCredit(lookupMaps())(baseAttainment)
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
id: 'att1',
|
||||||
|
grade: '5',
|
||||||
|
student_studentnumber: '014123456',
|
||||||
|
credits: 5,
|
||||||
|
createdate: '2020-01-10',
|
||||||
|
credittypecode: 'PASSED',
|
||||||
|
attainment_date: '2020-01-15',
|
||||||
|
course_id: 'cu1',
|
||||||
|
course_code: 'TKT10',
|
||||||
|
semestercode: 100,
|
||||||
|
semester_composite: '2020-2021-fall',
|
||||||
|
isStudyModule: false,
|
||||||
|
org: 'uni-org1',
|
||||||
|
language: 'fi',
|
||||||
|
is_open: false,
|
||||||
|
studyright_id: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when there is no target semester for the attainment date', () => {
|
||||||
|
getUniOrgId.mockReturnValue('uni-org1')
|
||||||
|
getSemesterByDate.mockReturnValue(undefined)
|
||||||
|
|
||||||
|
expect(mapCredit(lookupMaps())(baseAttainment)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the course code cannot be resolved', () => {
|
||||||
|
getUniOrgId.mockReturnValue('uni-org1')
|
||||||
|
getSemesterByDate.mockReturnValue({ semestercode: 100, composite: '2020-2021-fall' })
|
||||||
|
|
||||||
|
const result = mapCredit(lookupMaps({ courseUnitIdToCourseCode: {} }))(baseAttainment)
|
||||||
|
|
||||||
|
expect(result).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks open university attainments via the AY course code prefix', () => {
|
||||||
|
getUniOrgId.mockReturnValue('uni-org1')
|
||||||
|
getSemesterByDate.mockReturnValue({ semestercode: 100, composite: '2020-2021-fall' })
|
||||||
|
getGrade.mockReturnValue({ value: '5', passed: true })
|
||||||
|
getCreditTypeCodeFromAttainment.mockReturnValue('PASSED')
|
||||||
|
|
||||||
|
const result = mapCredit(lookupMaps({ courseUnitIdToCourseCode: { cu1: 'AY123' } }))(baseAttainment)
|
||||||
|
|
||||||
|
expect(result.is_open).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks open university attainments via the study right education type', () => {
|
||||||
|
getUniOrgId.mockReturnValue('uni-org1')
|
||||||
|
getSemesterByDate.mockReturnValue({ semestercode: 100, composite: '2020-2021-fall' })
|
||||||
|
getGrade.mockReturnValue({ value: '5', passed: true })
|
||||||
|
getCreditTypeCodeFromAttainment.mockReturnValue('PASSED')
|
||||||
|
|
||||||
|
const attainment = { ...baseAttainment, study_right_id: 'sr1' }
|
||||||
|
const result = mapCredit(
|
||||||
|
lookupMaps({
|
||||||
|
studyRightIdToEducationType: { sr1: 'urn:code:education-type:non-degree-education:open-university-studies' },
|
||||||
|
})
|
||||||
|
)(attainment)
|
||||||
|
|
||||||
|
expect(result.is_open).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null with missing organization', () => {
|
||||||
|
const attainment = { ...baseAttainment, organisations: undefined }
|
||||||
|
|
||||||
|
expect(mapCredit(lookupMaps())(attainment)).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { enrollmentMapper } from '@/updater/mapper.js'
|
||||||
|
import { getSemesterByDate } from '@/updater/shared.js'
|
||||||
|
|
||||||
|
describe('enrollmentMapper', () => {
|
||||||
|
const baseEnrollment = {
|
||||||
|
id: 'e1',
|
||||||
|
person_id: 'p1',
|
||||||
|
course_unit_id: 'cu1',
|
||||||
|
course_unit_realisation_id: 'real1',
|
||||||
|
enrolment_date_time: '2020-01-01T00:00:00Z',
|
||||||
|
state: 'ACTIVE',
|
||||||
|
study_right_id: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapEnrollment = (overrides = {}) =>
|
||||||
|
enrollmentMapper(
|
||||||
|
overrides.personIdToStudentNumber ?? { p1: '014111111' },
|
||||||
|
overrides.courseUnitIdToCourseUnit ?? { cu1: { code: 'TKT10', group_id: 'grp1' } },
|
||||||
|
overrides.realisationIdToActivityPeriod ?? {},
|
||||||
|
overrides.studyRightIdToEducationType ?? {}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('returns null when the student number cannot be resolved', () => {
|
||||||
|
expect(mapEnrollment({ personIdToStudentNumber: {} })(baseEnrollment)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null when the course unit cannot be resolved', () => {
|
||||||
|
expect(mapEnrollment({ courseUnitIdToCourseUnit: {} })(baseEnrollment)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps an enrollment correctly', () => {
|
||||||
|
getSemesterByDate.mockReturnValue({ semestercode: 5, composite: 'sem5' })
|
||||||
|
|
||||||
|
expect(mapEnrollment()(baseEnrollment)).toEqual({
|
||||||
|
id: 'e1',
|
||||||
|
studentnumber: '014111111',
|
||||||
|
state: 'ACTIVE',
|
||||||
|
course_code: 'TKT10',
|
||||||
|
semestercode: 5,
|
||||||
|
semester_composite: 'sem5',
|
||||||
|
enrollment_date_time: '2020-01-01T00:00:00Z',
|
||||||
|
is_open: false,
|
||||||
|
course_id: 'cu1',
|
||||||
|
course_group_id: 'grp1',
|
||||||
|
studyright_id: null,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// mapper.js imports logger.js and shared.js at module load time. shared.js transitively
|
||||||
|
// connects to Redis and instantiates Sequelize models as a side effect of being imported, so both
|
||||||
|
// are mocked here to keep these unit tests pure and fast.
|
||||||
|
//
|
||||||
|
// NOTE: Import this module first - before importing anything from mapper.js - in every test file in
|
||||||
|
// this directory. Tests that need per-case control over a shared.js lookup (e.g. creditMapper,
|
||||||
|
// enrollmentMapper) can still `import { getSemesterByDate, ... } from '.../updater/shared.js'`
|
||||||
|
// afterwards and call `.mockReturnValue(...)` on it - it's the same mocked function instance.
|
||||||
|
vi.mock('@/utils/logger.js', () => ({
|
||||||
|
default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/updater/shared.js', () => ({
|
||||||
|
getSemesterByDate: vi.fn(),
|
||||||
|
getGrade: vi.fn(),
|
||||||
|
getUniOrgId: vi.fn(),
|
||||||
|
getCountry: vi.fn(),
|
||||||
|
getCreditTypeCodeFromAttainment: vi.fn(),
|
||||||
|
educationTypeToExtentcode: { 'edu-type-1': 1 },
|
||||||
|
}))
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { isModule } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('isModule', () => {
|
||||||
|
it('is true for module attainment types', () => {
|
||||||
|
expect(isModule('ModuleAttainment')).toBe(true)
|
||||||
|
expect(isModule('CustomModuleAttainment')).toBe(true)
|
||||||
|
expect(isModule('DegreeProgrammeAttainment')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is false for course unit attainment types', () => {
|
||||||
|
expect(isModule('CourseUnitAttainment')).toBe(false)
|
||||||
|
expect(isModule('CustomCourseUnitAttainment')).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { mapCourseType } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('mapCourseType', () => {
|
||||||
|
it('maps a study level to a course type row', () => {
|
||||||
|
expect(mapCourseType({ id: 'lvl1', name: { fi: 'Taso' } })).toEqual({
|
||||||
|
coursetypecode: 'lvl1',
|
||||||
|
name: { fi: 'Taso' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { mapCurriculumPeriod } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('mapCurriculumPeriod', () => {
|
||||||
|
it('maps a curriculum period correctly', () => {
|
||||||
|
expect(
|
||||||
|
mapCurriculumPeriod({
|
||||||
|
id: 'cp1',
|
||||||
|
name: { fi: 'Kausi' },
|
||||||
|
university_org_id: 'org1',
|
||||||
|
active_period: { startDate: '2020-01-01', endDate: '2020-12-31' },
|
||||||
|
})
|
||||||
|
).toEqual({
|
||||||
|
id: 'cp1',
|
||||||
|
name: { fi: 'Kausi' },
|
||||||
|
universityOrgId: 'org1',
|
||||||
|
startDate: new Date('2020-01-01'),
|
||||||
|
endDate: new Date('2020-12-31'),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { mapStudyrightExtent } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('mapStudyrightExtent', () => {
|
||||||
|
it('resolves the extent code from educationTypeToExtentcode', () => {
|
||||||
|
expect(mapStudyrightExtent({ id: 'edu-type-1', name: { fi: 'Koulutus' } })).toEqual({
|
||||||
|
extentcode: 1,
|
||||||
|
name: { fi: 'Koulutus' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { mapTeacher } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('mapTeacher', () => {
|
||||||
|
it('combines last and first names when present', () => {
|
||||||
|
expect(mapTeacher({ id: 'p1', last_name: 'Smith', first_names: 'John' })).toEqual({
|
||||||
|
id: 'p1',
|
||||||
|
name: 'Smith John',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to just the last name when first names are missing', () => {
|
||||||
|
expect(mapTeacher({ id: 'p2', last_name: 'Doe' })).toEqual({ id: 'p2', name: 'Doe' })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { sanitizeCourseCode } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('sanitizeCourseCode', () => {
|
||||||
|
it('returns null for falsy input', () => {
|
||||||
|
expect(sanitizeCourseCode(null)).toBeNull()
|
||||||
|
expect(sanitizeCourseCode('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns the code as-is when there is no surrogate suffix', () => {
|
||||||
|
expect(sanitizeCourseCode('TKT10')).toBe('TKT10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a short second segment', () => {
|
||||||
|
expect(sanitizeCourseCode('a1b2-c3d4')).toBe('a1b2-c3d4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops a long (Oodi surrogate) second segment', () => {
|
||||||
|
expect(sanitizeCourseCode('TKT10-123456789')).toBe('TKT10')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { studentMapper } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('studentMapper', () => {
|
||||||
|
const baseStudent = {
|
||||||
|
last_name: 'Doe',
|
||||||
|
first_names: 'Jane',
|
||||||
|
student_number: '014999999',
|
||||||
|
primary_email: 'jane@example.com',
|
||||||
|
secondary_email: null,
|
||||||
|
phone_number: null,
|
||||||
|
gender_urn: 'urn:code:gender:female',
|
||||||
|
oppija_id: 'oppija1',
|
||||||
|
date_of_birth: '2000-01-01',
|
||||||
|
id: 'person1',
|
||||||
|
has_personal_identity_code: true,
|
||||||
|
preferred_language_urn: 'urn:code:language:fi',
|
||||||
|
citizenships: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
it('filters out the Oodi test student', () => {
|
||||||
|
const testStudent = { ...baseStudent, student_number: '012023965' }
|
||||||
|
expect(studentMapper([], [], new Set())(testStudent)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sums credits from valid, primary attainments of the student', () => {
|
||||||
|
const attainments = [
|
||||||
|
{
|
||||||
|
id: 'a1',
|
||||||
|
person_id: 'person1',
|
||||||
|
credits: 5,
|
||||||
|
primary: true,
|
||||||
|
misregistration: false,
|
||||||
|
expiryDate: new Date('2099-01-01'),
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
state: 'ATTAINED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'b1',
|
||||||
|
person_id: 'person1',
|
||||||
|
credits: 5,
|
||||||
|
primary: true,
|
||||||
|
misregistration: false,
|
||||||
|
expiryDate: new Date('2099-01-01'),
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
state: 'ATTAINED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// improved (non-primary) attainment must not be counted
|
||||||
|
id: 'a2',
|
||||||
|
person_id: 'person1',
|
||||||
|
credits: 3,
|
||||||
|
primary: false,
|
||||||
|
misregistration: false,
|
||||||
|
expiryDate: new Date('2099-01-01'),
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
state: 'ATTAINED',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// belongs to a different student
|
||||||
|
id: 'a3',
|
||||||
|
person_id: 'other',
|
||||||
|
credits: 100,
|
||||||
|
primary: true,
|
||||||
|
misregistration: false,
|
||||||
|
expiryDate: new Date('2099-01-01'),
|
||||||
|
type: 'CourseUnitAttainment',
|
||||||
|
state: 'ATTAINED',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const result = studentMapper(attainments, [], new Set())(baseStudent)
|
||||||
|
|
||||||
|
expect(result.creditcount).toBe(10)
|
||||||
|
expect(result.studentnumber).toBe('014999999')
|
||||||
|
expect(result.abbreviatedname).toBe('Doe Jane')
|
||||||
|
expect(result.dateofuniversityenrollment).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { termRegistrationTypeToEnrollmenttype } from '@/updater/mapper.js'
|
||||||
|
|
||||||
|
describe('termRegistrationTypeToEnrollmenttype', () => {
|
||||||
|
it('maps known types and falls back to 3', () => {
|
||||||
|
expect(termRegistrationTypeToEnrollmenttype('ATTENDING')).toBe(1)
|
||||||
|
expect(termRegistrationTypeToEnrollmenttype('NONATTENDING')).toBe(2)
|
||||||
|
expect(termRegistrationTypeToEnrollmenttype('SOMETHING_ELSE')).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { educationTypeToExtentcode } from '@/updater/shared.js'
|
||||||
|
|
||||||
|
describe('educationTypeToExtentcode', () => {
|
||||||
|
it('maps known education types to their extent code', () => {
|
||||||
|
expect(educationTypeToExtentcode['urn:code:education-type:degree-education:bachelors-degree']).toBe(1)
|
||||||
|
expect(educationTypeToExtentcode['urn:code:education-type:degree-education:masters-degree']).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('maps education types without their own extent code to null', () => {
|
||||||
|
expect(
|
||||||
|
educationTypeToExtentcode['urn:code:education-type:non-degree-education:agreement-studies:joo-studies']
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+27
@@ -0,0 +1,27 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import './initTests.js'
|
||||||
|
|
||||||
|
import { getCreditTypeCodeFromAttainment } from '@/updater/shared.js'
|
||||||
|
|
||||||
|
describe('getCreditTypeCodeFromAttainment', () => {
|
||||||
|
it('is FAILED when the grade did not pass', () => {
|
||||||
|
expect(getCreditTypeCodeFromAttainment({ primary: true, state: 'ATTAINED' }, false)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is FAILED when the state is FAILED, even if passed is true', () => {
|
||||||
|
expect(getCreditTypeCodeFromAttainment({ primary: true, state: 'FAILED' }, true)).toBe(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is IMPROVED for a passed, non-primary attainment', () => {
|
||||||
|
expect(getCreditTypeCodeFromAttainment({ primary: false, state: 'ATTAINED' }, true)).toBe(7)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is PASSED for a passed, primary, ATTAINED attainment', () => {
|
||||||
|
expect(getCreditTypeCodeFromAttainment({ primary: true, state: 'ATTAINED' }, true)).toBe(4)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('is APPROVED for a passed, primary attainment in any other state', () => {
|
||||||
|
expect(getCreditTypeCodeFromAttainment({ primary: true, state: 'INCLUDED' }, true)).toBe(9)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
|
// shared.js connects to Redis and loads every Sequelize model as a side effect of being imported.
|
||||||
|
// Mock those three dependencies so importing its pure exports doesn't require a live Redis/DB.
|
||||||
|
//
|
||||||
|
// Import this module first - before importing anything from shared.js - in every test file in
|
||||||
|
// this directory.
|
||||||
|
vi.mock('@/utils/redis.js', () => ({
|
||||||
|
redisClient: { get: vi.fn(), set: vi.fn(), connect: vi.fn(), on: vi.fn() },
|
||||||
|
lock: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/db/index.js', () => ({
|
||||||
|
selectAllFrom: vi.fn(),
|
||||||
|
selectAllFromSnapshots: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/db/models/index.js', () => ({
|
||||||
|
Semester: {},
|
||||||
|
Organization: {},
|
||||||
|
CREDIT_TYPE_CODES: { PASSED: 4, FAILED: 10, IMPROVED: 7, APPROVED: 9 },
|
||||||
|
}))
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import path from 'path'
|
||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(import.meta.dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
exclude: ['node_modules'],
|
||||||
|
testTimeout: 10_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user