Skip to content
This repository was archived by the owner on Jun 9, 2026. It is now read-only.

Commit d1e0ab1

Browse files
authored
feat(migrations): codo events migration (#106)
1 parent d91a707 commit d1e0ab1

8 files changed

Lines changed: 748 additions & 7 deletions
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import {
2+
buildTestContext,
3+
DocStore,
4+
runIdempotencyCheck,
5+
} from '../testing/migration-idempotency.harness';
6+
import { codoAddEventType } from './codo-add-event-type.migration';
7+
8+
/** Minimal codo-like config without any event-related entries. */
9+
function baseSeed(): DocStore {
10+
return {
11+
'app/Config:CONFIG_ENTITY': {
12+
_id: 'Config:CONFIG_ENTITY',
13+
data: {
14+
navigationMenu: {
15+
items: [
16+
{ entityType: 'Child' },
17+
{ entityType: 'ChildSchoolRelation' },
18+
{ entityType: 'Note' },
19+
{ entityType: 'Todo' },
20+
],
21+
},
22+
'entity:Note': {
23+
label: 'Notiz',
24+
attributes: {
25+
relatedEntities: {
26+
dataType: 'entity',
27+
additional: 'ChildSchoolRelation',
28+
label: 'Patenschaft/en',
29+
isArray: true,
30+
},
31+
},
32+
},
33+
'entity:Todo': {
34+
label: 'Aufgabe',
35+
attributes: {
36+
relatedEntities: {
37+
dataType: 'entity',
38+
additional: ['Child', 'School', 'ChildSchoolRelation'],
39+
isArray: true,
40+
},
41+
},
42+
},
43+
'view:mentor/:id': {
44+
config: {
45+
panels: [
46+
{ title: 'Mentor:in', components: [{ component: 'Form' }] },
47+
{
48+
title: 'Notizen & Aufgaben',
49+
components: [
50+
{ component: 'NotesRelatedToEntity' },
51+
{ component: 'TodosRelatedToEntity' },
52+
],
53+
},
54+
],
55+
},
56+
},
57+
'view:mentee/:id': {
58+
config: {
59+
panels: [
60+
{ title: 'Mentee', components: [{ component: 'Form' }] },
61+
{
62+
title: 'Notizen & Aufgaben',
63+
components: [
64+
{ component: 'NotesRelatedToEntity' },
65+
{ component: 'TodosRelatedToEntity' },
66+
],
67+
},
68+
],
69+
},
70+
},
71+
'view:matching': { component: 'MatchingEntities' },
72+
},
73+
},
74+
};
75+
}
76+
77+
describe('codoAddEventType migration', () => {
78+
it('should apply all changes on first run', async () => {
79+
const store = baseSeed();
80+
const ctx = buildTestContext(store);
81+
const result = await codoAddEventType.run(ctx);
82+
83+
expect(result.changed).toBe(true);
84+
expect(result.status).toBe('ok');
85+
86+
// Enum docs created
87+
expect(store['app/ConfigurableEnum:artDerVeranstaltung']).toBeDefined();
88+
expect(store['app/ConfigurableEnum:attendance-status']).toBeDefined();
89+
90+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
91+
const config = store['app/Config:CONFIG_ENTITY'] as any;
92+
93+
// Entity and view configs added
94+
expect(config.data['entity:event']).toBeDefined();
95+
expect(config.data['entity:event'].label).toBe('Veranstaltung');
96+
expect(config.data['view:event']).toBeDefined();
97+
expect(config.data['view:event/:id']).toBeDefined();
98+
99+
// Note relatedEntities updated (string → array with event)
100+
expect(
101+
config.data['entity:Note'].attributes.relatedEntities.additional,
102+
).toEqual(['ChildSchoolRelation', 'event']);
103+
104+
// Note relatedEntities label renamed
105+
expect(config.data['entity:Note'].attributes.relatedEntities.label).toBe(
106+
'Sonstige verknüpfte Datensätze',
107+
);
108+
109+
// Todo relatedEntities updated (array += event)
110+
expect(
111+
config.data['entity:Todo'].attributes.relatedEntities.additional,
112+
).toEqual(['Child', 'School', 'ChildSchoolRelation', 'event']);
113+
114+
// Nav menu updated – event inserted before Note
115+
const items = config.data.navigationMenu.items;
116+
const eventIdx = items.findIndex((i) => i.entityType === 'event');
117+
const noteIdx = items.findIndex((i) => i.entityType === 'Note');
118+
expect(eventIdx).toBeGreaterThanOrEqual(0);
119+
expect(noteIdx).toBeGreaterThan(eventIdx);
120+
121+
// Veranstaltungen added to mentor view
122+
const mentorPanels = config.data['view:mentor/:id'].config.panels;
123+
const mentorTab = mentorPanels.find(
124+
(p) => p.title === 'Notizen & Aufgaben',
125+
);
126+
expect(mentorTab.components).toContainEqual(
127+
expect.objectContaining({
128+
component: 'RelatedEntities',
129+
config: expect.objectContaining({ entityType: 'event' }),
130+
}),
131+
);
132+
133+
// Veranstaltungen added to mentee view
134+
const menteePanels = config.data['view:mentee/:id'].config.panels;
135+
const menteeTab = menteePanels.find(
136+
(p) => p.title === 'Notizen & Aufgaben',
137+
);
138+
expect(menteeTab.components).toContainEqual(
139+
expect.objectContaining({
140+
component: 'RelatedEntities',
141+
config: expect.objectContaining({ entityType: 'event' }),
142+
}),
143+
);
144+
});
145+
146+
it('should be idempotent (second run reports no-change)', async () => {
147+
const result = await runIdempotencyCheck(codoAddEventType, baseSeed());
148+
149+
expect(result.secondRunResult.changed).toBe(false);
150+
expect(result.secondRunResult.status).toBe('no-change');
151+
expect(result.stateAfterSecondRun).toEqual(result.stateAfterFirstRun);
152+
});
153+
154+
it('should handle missing config document', async () => {
155+
const ctx = buildTestContext({});
156+
const result = await codoAddEventType.run(ctx);
157+
158+
expect(result.status).toBe('failed');
159+
expect(result.changed).toBe(false);
160+
});
161+
162+
it('should handle missing data object in config', async () => {
163+
const store: DocStore = {
164+
'app/Config:CONFIG_ENTITY': { _id: 'Config:CONFIG_ENTITY' },
165+
};
166+
const ctx = buildTestContext(store);
167+
const result = await codoAddEventType.run(ctx);
168+
169+
expect(result.status).toBe('failed');
170+
expect(result.warnings).toContainEqual(
171+
expect.stringContaining('no data object'),
172+
);
173+
});
174+
175+
it('should skip existing enum docs without overwriting', async () => {
176+
const store = baseSeed();
177+
store['app/ConfigurableEnum:artDerVeranstaltung'] = {
178+
_id: 'ConfigurableEnum:artDerVeranstaltung',
179+
values: [{ id: 'CUSTOM', label: 'Custom' }],
180+
};
181+
store['app/ConfigurableEnum:attendance-status'] = {
182+
_id: 'ConfigurableEnum:attendance-status',
183+
values: [{ id: 'X', label: 'X' }],
184+
};
185+
186+
const ctx = buildTestContext(store);
187+
const result = await codoAddEventType.run(ctx);
188+
189+
expect(result.changed).toBe(true); // Config changes still applied
190+
191+
// Enum docs untouched
192+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
193+
const artEnum = store['app/ConfigurableEnum:artDerVeranstaltung'] as any;
194+
expect(artEnum.values[0].id).toBe('CUSTOM');
195+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
196+
const attEnum = store['app/ConfigurableEnum:attendance-status'] as any;
197+
expect(attEnum.values[0].id).toBe('X');
198+
});
199+
200+
it('should skip existing entity:event without overwriting', async () => {
201+
const store = baseSeed();
202+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
203+
(store['app/Config:CONFIG_ENTITY'] as any).data['entity:event'] = {
204+
label: 'Custom Event',
205+
};
206+
207+
const ctx = buildTestContext(store);
208+
const result = await codoAddEventType.run(ctx);
209+
210+
expect(result.changed).toBe(true); // Other changes still applied
211+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
212+
const config = store['app/Config:CONFIG_ENTITY'] as any;
213+
expect(config.data['entity:event'].label).toBe('Custom Event');
214+
});
215+
216+
it('should handle missing mentor/mentee views with warnings', async () => {
217+
const store = baseSeed();
218+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
219+
const data = (store['app/Config:CONFIG_ENTITY'] as any).data;
220+
delete data['view:mentor/:id'];
221+
delete data['view:mentee/:id'];
222+
223+
const ctx = buildTestContext(store);
224+
const result = await codoAddEventType.run(ctx);
225+
226+
expect(result.changed).toBe(true);
227+
expect(result.warnings).toContainEqual(
228+
expect.stringContaining('view:mentor/:id'),
229+
);
230+
expect(result.warnings).toContainEqual(
231+
expect.stringContaining('view:mentee/:id'),
232+
);
233+
});
234+
235+
it('should handle missing relatedEntities attribute with warning', async () => {
236+
const store = baseSeed();
237+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
238+
const data = (store['app/Config:CONFIG_ENTITY'] as any).data;
239+
delete data['entity:Note'].attributes.relatedEntities;
240+
241+
const ctx = buildTestContext(store);
242+
const result = await codoAddEventType.run(ctx);
243+
244+
expect(result.changed).toBe(true);
245+
expect(result.warnings).toContainEqual(
246+
expect.stringContaining('entity:Note'),
247+
);
248+
});
249+
250+
it('should handle detail view without matching tab', async () => {
251+
const store = baseSeed();
252+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
253+
const data = (store['app/Config:CONFIG_ENTITY'] as any).data;
254+
data['view:mentor/:id'].config.panels = [
255+
{ title: 'Sonstiges', components: [{ component: 'Form' }] },
256+
];
257+
258+
const ctx = buildTestContext(store);
259+
const result = await codoAddEventType.run(ctx);
260+
261+
expect(result.changed).toBe(true);
262+
expect(result.warnings).toContainEqual(
263+
expect.stringContaining('view:mentor/:id'),
264+
);
265+
expect(result.warnings).toContainEqual(
266+
expect.stringContaining('manual review'),
267+
);
268+
});
269+
270+
it('should find tab by content-based fallback', async () => {
271+
const store = baseSeed();
272+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
273+
const data = (store['app/Config:CONFIG_ENTITY'] as any).data;
274+
// Rename the tab title to something unusual
275+
data['view:mentor/:id'].config.panels[1].title = 'Alles Mögliche';
276+
277+
const ctx = buildTestContext(store);
278+
const result = await codoAddEventType.run(ctx);
279+
280+
expect(result.changed).toBe(true);
281+
// Should still find the tab via content-based fallback
282+
const panel = data['view:mentor/:id'].config.panels[1];
283+
expect(panel.components).toContainEqual(
284+
expect.objectContaining({
285+
component: 'RelatedEntities',
286+
config: expect.objectContaining({ entityType: 'event' }),
287+
}),
288+
);
289+
});
290+
291+
it('should append event to end of nav menu when Note is absent', async () => {
292+
const store = baseSeed();
293+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
294+
const data = (store['app/Config:CONFIG_ENTITY'] as any).data;
295+
data.navigationMenu.items = [
296+
{ entityType: 'Child' },
297+
{ entityType: 'Todo' },
298+
];
299+
300+
const ctx = buildTestContext(store);
301+
const result = await codoAddEventType.run(ctx);
302+
303+
expect(result.changed).toBe(true);
304+
const items = data.navigationMenu.items;
305+
expect(items[items.length - 1]).toEqual({ entityType: 'event' });
306+
});
307+
});

0 commit comments

Comments
 (0)