Skip to content

Commit bc9918d

Browse files
authored
Merge pull request #25 from opencrvs/adding-tests
Add tests and github action to run them
2 parents 0154a43 + 6a0d711 commit bc9918d

25 files changed

+6144
-213
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
name: Test Data Migration
2+
3+
on:
4+
pull_request:
5+
paths:
6+
- 'v1-to-v2-data-migration/**'
7+
- '.github/workflows/test-migration.yml'
8+
9+
jobs:
10+
test:
11+
name: Run Migration Tests
12+
runs-on: ubuntu-latest
13+
14+
steps:
15+
- name: Checkout repository
16+
uses: actions/checkout@v4
17+
18+
- name: Setup Deno
19+
uses: denoland/setup-deno@v1
20+
with:
21+
deno-version: v2.x
22+
23+
- name: Cache Deno dependencies
24+
uses: actions/cache@v4
25+
with:
26+
path: |
27+
~/.deno
28+
~/.cache/deno
29+
key: ${{ runner.os }}-deno-${{ hashFiles('v1-to-v2-data-migration/tests/deno.lock') }}
30+
restore-keys: |
31+
${{ runner.os }}-deno-
32+
33+
- name: Detect country and run tests
34+
working-directory: v1-to-v2-data-migration/tests
35+
run: |
36+
echo "🧪 Running migration tests with automatic country detection..."
37+
deno task test
38+
39+
- name: Test Summary
40+
if: always()
41+
working-directory: v1-to-v2-data-migration/tests
42+
run: |
43+
# Extract country code from addressResolver.ts
44+
COUNTRY=$(deno eval "import { COUNTRY_CODE } from '../countryData/addressResolver.ts'; console.log(COUNTRY_CODE)")
45+
46+
echo "## 🧪 Migration Test Results" >> $GITHUB_STEP_SUMMARY
47+
echo "" >> $GITHUB_STEP_SUMMARY
48+
echo "**Branch:** \`${{ github.ref_name }}\`" >> $GITHUB_STEP_SUMMARY
49+
echo "**Country:** \`$COUNTRY\`" >> $GITHUB_STEP_SUMMARY
50+
echo "" >> $GITHUB_STEP_SUMMARY
51+
echo "### 📦 Test Suites Executed" >> $GITHUB_STEP_SUMMARY
52+
echo "- ✅ Common unit tests (\`unit/\`)" >> $GITHUB_STEP_SUMMARY
53+
echo "- ✅ Country-specific tests (\`$COUNTRY/\`)" >> $GITHUB_STEP_SUMMARY
54+
echo "" >> $GITHUB_STEP_SUMMARY
55+
echo "### 🎯 Tests Included" >> $GITHUB_STEP_SUMMARY
56+
echo "**Common Tests:**" >> $GITHUB_STEP_SUMMARY
57+
echo "- Birth resolver tests" >> $GITHUB_STEP_SUMMARY
58+
echo "- Death resolver tests" >> $GITHUB_STEP_SUMMARY
59+
echo "- Action mapping tests" >> $GITHUB_STEP_SUMMARY
60+
echo "- Corrections tests" >> $GITHUB_STEP_SUMMARY
61+
echo "- Transform function tests" >> $GITHUB_STEP_SUMMARY
62+
echo "- Post-process tests" >> $GITHUB_STEP_SUMMARY
63+
echo "" >> $GITHUB_STEP_SUMMARY
64+
echo "**Country-Specific Tests:**" >> $GITHUB_STEP_SUMMARY
65+
echo "- Address resolution tests" >> $GITHUB_STEP_SUMMARY
66+
echo "- Address corrections tests" >> $GITHUB_STEP_SUMMARY

v1-to-v2-data-migration/countryData/addressResolver.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ export function resolveAddress(
3838
if (!address) {
3939
return null
4040
}
41-
const lines = address.line.filter(Boolean).filter((line) => !['URBAN', 'RURAL'].includes(line))
41+
const lines = address.line
42+
.filter(Boolean)
43+
.filter((line) => !['URBAN', 'RURAL'].includes(line))
4244
const international = address.country !== COUNTRY_CODE
4345
if (international) {
4446
return {
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,6 @@
11
export const countryResolver = {}
2+
3+
// The V1 response will populate both the informant and special informant fields
4+
// so we need to check if the informant is a special informant to avoid duplication
5+
export const birthSpecialInformants = ['MOTHER', 'FATHER']
6+
export const deathSpecialInformants = ['SPOUSE']
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* @example
3+
* normalizeDateString('2025-3-3') // returns '2025-03-03'
4+
*/
5+
export function normalizeDateString(
6+
dateStr: string | undefined
7+
): string | undefined {
8+
if (!dateStr) {
9+
return dateStr
10+
}
11+
12+
const datePattern = /^(\d{4})-(\d{1,2})-(\d{1,2})$/
13+
const match = dateStr.match(datePattern)
14+
15+
if (!match) {
16+
return dateStr
17+
}
18+
19+
const [, year, month, day] = match
20+
21+
const paddedMonth = month.padStart(2, '0')
22+
const paddedDay = day.padStart(2, '0')
23+
24+
return `${year}-${paddedMonth}-${paddedDay}`
25+
}
26+
27+
export function isDateField(fieldId: string): boolean {
28+
const dateFieldPatterns = ['BirthDate', 'birthDate', 'Date', 'deathDate']
29+
30+
return dateFieldPatterns.some((pattern) => fieldId.includes(pattern))
31+
}

v1-to-v2-data-migration/helpers/defaultMappings.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,7 +89,7 @@ export const DEFAULT_FIELD_MAPPINGS = {
8989
'death.spouse.reasonNotApplying': 'spouse.reason',
9090
'death.spouse.spouseBirthDate': 'spouse.dob',
9191
'death.spouse.exactDateOfBirthUnknown': 'spouse.dobUnknown',
92-
'death.spouse.ageOfIndividualInYears': 'spouse.age',
92+
// age mapping handled by AGE_MAPPINGS to transform to object with asOfDateRef
9393
'death.spouse.nationality': 'spouse.nationality',
9494
'death.spouse.spouseNationalId': 'spouse.nid',
9595
'death.spouse.spousePassport': 'spouse.passport',
@@ -122,7 +122,7 @@ export const CUSTOM_FIELD_MAPPINGS = {
122122
'death.spouse.spouse-view-group.verified': 'spouse.verified',
123123
}
124124

125-
export const VERIFIED_MAPPINGS : Record<
125+
export const VERIFIED_MAPPINGS: Record<
126126
string,
127127
(data: string) => Record<string, any>
128128
> = {
@@ -187,4 +187,3 @@ export const AGE_MAPPINGS: Record<
187187
},
188188
}),
189189
}
190-

v1-to-v2-data-migration/helpers/resolverUtils.ts

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import { Identifier, Document, ProcessedDocumentWithOptionType, PersonWithIdentifiers, ProcessedDocument } from './types.ts'
1+
import {
2+
birthSpecialInformants,
3+
deathSpecialInformants,
4+
} from '../countryData/countryResolvers.ts'
5+
import {
6+
Identifier,
7+
Document,
8+
ProcessedDocumentWithOptionType,
9+
PersonWithIdentifiers,
10+
ProcessedDocument,
11+
} from './types.ts'
212

313
export const getIdentifier = (
414
data: { identifier?: Identifier[] } | undefined,
@@ -16,7 +26,7 @@ export const getDocument = (
1626
return {
1727
path: doc.uri,
1828
originalFilename: doc.uri.replace('/ocrvs/', ''),
19-
type: doc.contentType
29+
type: doc.contentType,
2030
}
2131
})[0]
2232
if (!document) {
@@ -50,20 +60,21 @@ export function getCustomField(data: any, id: string): any {
5060
)?.value
5161
}
5262

53-
5463
/**
5564
* Special informants have their own special sections like `mother.`, `father.` or `spouse.`.
5665
*/
57-
export const isSpecialInformant = (informant: PersonWithIdentifiers | undefined, eventType: 'birth' | 'death') => {
58-
if (!informant) return false
66+
export const isSpecialInformant = (
67+
informant: PersonWithIdentifiers | undefined,
68+
eventType: 'birth' | 'death'
69+
) => {
70+
if (!informant?.relationship) return false
5971

60-
if(eventType === 'birth') {
61-
return informant.relationship === 'MOTHER'
62-
|| informant.relationship === 'FATHER'
72+
if (eventType === 'birth') {
73+
return birthSpecialInformants.includes(informant.relationship)
6374
}
6475

65-
if(eventType === 'death') {
66-
return informant.relationship === 'SPOUSE'
76+
if (eventType === 'death') {
77+
return deathSpecialInformants.includes(informant.relationship)
6778
}
6879
return false
69-
}
80+
}

v1-to-v2-data-migration/helpers/transform.ts

Lines changed: 105 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
AGE_MAPPINGS,
77
VERIFIED_MAPPINGS,
88
} from './defaultMappings.ts'
9+
import { normalizeDateString, isDateField } from './dateUtils.ts'
910
import { COUNTRY_FIELD_MAPPINGS } from '../countryData/countryMappings.ts'
1011
import { NAME_MAPPINGS } from '../countryData/nameMappings.ts'
1112
import { ADDRESS_MAPPINGS } from '../countryData/addressMappings.ts'
@@ -124,7 +125,11 @@ export function transformCorrection(
124125

125126
const v1OutputDeclaration =
126127
historyItem.output?.reduce((acc: Record<string, any>, curr: any) => {
127-
acc[`${event}.${curr.valueCode}.${curr.valueId}`] = curr.value
128+
// Normalize date strings in output to ensure proper zero-padding
129+
const value = isDateField(curr.valueId)
130+
? normalizeDateString(curr.value)
131+
: curr.value
132+
acc[`${event}.${curr.valueCode}.${curr.valueId}`] = value
128133
return acc
129134
}, {}) || {}
130135

@@ -469,34 +474,114 @@ export function transform(
469474
return postProcess(documents)
470475
}
471476

477+
/**
478+
* Deep merge two objects, with priority given to source values
479+
*/
480+
function deepMerge(target: any, source: any): any {
481+
const result = { ...target }
482+
483+
for (const key in source) {
484+
if (source.hasOwnProperty(key)) {
485+
const sourceValue = source[key]
486+
const targetValue = target[key]
487+
488+
// If both are plain objects, merge recursively
489+
if (
490+
sourceValue &&
491+
typeof sourceValue === 'object' &&
492+
!Array.isArray(sourceValue) &&
493+
targetValue &&
494+
typeof targetValue === 'object' &&
495+
!Array.isArray(targetValue)
496+
) {
497+
result[key] = deepMerge(targetValue, sourceValue)
498+
} else {
499+
// Otherwise, use source value
500+
result[key] = sourceValue
501+
}
502+
}
503+
}
504+
505+
return result
506+
}
507+
472508
function postProcess(documents: TransformedDocument): TransformedDocument {
473509
const correctionResolverKeys = Object.keys(correctionResolver)
474510

511+
// Step 1: Build a map of current declaration state at each action
512+
// We start from the final declaration and work backwards
513+
let currentDeclaration: Record<string, any> = {}
514+
515+
// Find the final declaration by merging all DECLARE/REGISTER/VALIDATE declarations
516+
for (const action of documents.actions) {
517+
if (
518+
(action.type === 'DECLARE' ||
519+
action.type === 'REGISTER' ||
520+
action.type === 'VALIDATE') &&
521+
action.declaration &&
522+
Object.keys(action.declaration).length > 0
523+
) {
524+
currentDeclaration = deepMerge(currentDeclaration, action.declaration)
525+
}
526+
}
527+
528+
// Step 2: Process corrections in reverse order to reverse-engineer the original state
529+
const corrections: Array<{ index: number; action: Action }> = []
530+
475531
for (let i = 0; i < documents.actions.length; i++) {
476-
const action = documents.actions[i]
532+
if (documents.actions[i].type === 'REQUEST_CORRECTION') {
533+
corrections.push({ index: i, action: documents.actions[i] })
534+
}
535+
}
477536

478-
if (action.type === 'REQUEST_CORRECTION' && action.annotation) {
479-
// Filter out correctionResolver fields from the annotation
480-
const filteredAnnotation = Object.fromEntries(
481-
Object.entries(action.annotation).filter(
482-
([key]) => !correctionResolverKeys.includes(key)
483-
)
537+
// Process corrections from newest to oldest to reverse engineer
538+
for (let i = corrections.length - 1; i >= 0; i--) {
539+
const { action } = corrections[i]
540+
541+
if (!action.annotation || !action.declaration) {
542+
continue
543+
}
544+
545+
// Filter out correctionResolver metadata fields from annotation
546+
const filteredAnnotation = Object.fromEntries(
547+
Object.entries(action.annotation).filter(
548+
([key]) => !correctionResolverKeys.includes(key)
484549
)
550+
)
485551

486-
// Find the first action before this one with a non-empty declaration
487-
for (let j = i - 1; j >= 0; j--) {
488-
const previousAction = documents.actions[j]
489-
490-
if (
491-
previousAction.declaration &&
492-
typeof previousAction.declaration === 'object' &&
493-
Object.keys(previousAction.declaration).length > 0
494-
) {
495-
previousAction.declaration = filteredAnnotation
496-
break
497-
}
552+
// First, reverse the correction: for each field in the correction's declaration,
553+
// replace the current state with the input value (from filteredAnnotation)
554+
for (const key of Object.keys(action.declaration)) {
555+
if (filteredAnnotation.hasOwnProperty(key)) {
556+
currentDeclaration[key] = filteredAnnotation[key]
498557
}
499558
}
559+
560+
// Now set the annotation to the reversed state (which is the state BEFORE this correction)
561+
const newAnnotation = {
562+
...currentDeclaration,
563+
...Object.fromEntries(
564+
Object.entries(action.annotation).filter(([key]) =>
565+
correctionResolverKeys.includes(key)
566+
)
567+
),
568+
}
569+
570+
action.annotation = newAnnotation
571+
}
572+
573+
// Step 3: Update the base DECLARE/REGISTER/VALIDATE actions with the reverse-engineered state
574+
// Find the first action with a non-empty declaration
575+
for (const action of documents.actions) {
576+
if (
577+
(action.type === 'DECLARE' ||
578+
action.type === 'REGISTER' ||
579+
action.type === 'VALIDATE') &&
580+
action.declaration &&
581+
Object.keys(action.declaration).length > 0
582+
) {
583+
action.declaration = { ...currentDeclaration }
584+
}
500585
}
501586

502587
return documents

v1-to-v2-data-migration/helpers/types.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export interface Document {
4040
uri: string
4141
contentType: string
4242
type: string
43+
subject?: string
4344
}
4445

4546
export interface ProcessedDocumentWithOptionType {
@@ -56,6 +57,7 @@ export interface ProcessedDocument {
5657

5758
// Address types
5859
export interface AddressLine {
60+
type?: string
5961
line: string[]
6062
city?: string
6163
district?: string
@@ -111,7 +113,7 @@ export interface Registration {
111113
contactEmail?: string
112114
informantsSignature?: string
113115
attachments?: Document[]
114-
duplicates?: Array<{ compositionId: string }>
116+
duplicates?: Array<{ compositionId: string; trackingId?: string }>
115117
}
116118

117119
// History item types
@@ -177,7 +179,10 @@ export interface HistoryItem {
177179
}
178180

179181
// Resolver types
180-
export type ResolverFunction<T = any> = (data: T, eventType: 'birth' | 'death') => any
182+
export type ResolverFunction<T = any> = (
183+
data: T,
184+
eventType: 'birth' | 'death'
185+
) => any
181186

182187
export interface ResolverMap {
183188
[fieldId: string]: ResolverFunction
@@ -204,7 +209,7 @@ export interface EventRegistration {
204209
weightAtBirth?: number
205210
deathDate?: string
206211
deathDescription?: string
207-
causeOfDeathEstablished?: "true" | "false"
212+
causeOfDeathEstablished?: 'true' | 'false'
208213
causeOfDeathMethod?: string
209214
mannerOfDeath?: string
210215
}

0 commit comments

Comments
 (0)