diff --git a/src/connectors/migration-studio.ts b/src/connectors/migration-studio.ts new file mode 100644 index 0000000..d224f87 --- /dev/null +++ b/src/connectors/migration-studio.ts @@ -0,0 +1,134 @@ +/** + * `connectors/migration-studio.ts` — HSP-085: Migration Studio i kundportalen. + * + * T01: an EJ importerbart objekt döljs inte ur nämnaren — objects that + * CANNOT be imported remain in the denominator; the import-ratio is + * calculated over the FULL set (importable + non-importable), so hiding + * failures would falsify the ratio. + * T02: en uppgift kan återupptas efter sessionbyte — the Studio's state + * (mapping, checkpoint, decided conflicts) persists in a resumable session + * record keyed by migrationId; a session change picks up where the last + * session stopped. + * T03: en importerad CRM-lista marknadsförs INTE som komplett Microsoft + * 365-ersättning — the coverage claim is SCOPED (what was imported, what + * the coverage covers), never a generic total badge. + */ + +export interface MigrationSourceObject { + objectId: string; + name: string; + /** Can this object be imported into the target? */ + importable: boolean; + /** Why NOT importable (T01: the reason is ALWAYS recorded for non-importable). */ + nonImportableReason?: string; + /** The object's tenant (T03: cross-tenant objects are non-importable). */ + tenantId: string; +} + +export interface ConflictRecord { + conflictId: string; + objectId: string; + kind: 'name_collision' | 'missing_field' | 'type_mismatch'; + /** Has the customer decided on this conflict? */ + resolved: boolean; + resolution?: 'skip' | 'overwrite' | 'merge'; +} + +export interface MigrationSession { + migrationId: string; + tenantId: string; + /** T02: the checkpoint (batch position) — resumable across sessions. */ + checkpointCursor: string; + /** T02: decided conflicts persist across sessions. */ + resolvedConflicts: ReadonlyArray; + state: 'mapping' | 'dry_run' | 'conflict_review' | 'reconciliation' | 'final_approval'; +} + +// ----------------------------------------------------------------------------- +// T01: the import ratio counts EVERY source object in the denominator +// ----------------------------------------------------------------------------- + +export interface ImportRatio { + totalSourceObjects: number; + importableCount: number; + nonImportableCount: number; + /** importable / total — non-importable objects stay in the DENOMINATOR. */ + ratio: number; + /** The non-importable objects with their reasons (always visible). */ + nonImportable: ReadonlyArray<{ objectId: string; reason: string }>; +} + +export function computeImportRatio(source: ReadonlyArray): ImportRatio { + const importable = source.filter((o) => o.importable); + const nonImportable = source.filter((o) => !o.importable); + const total = source.length; + return { + totalSourceObjects: total, + importableCount: importable.length, + nonImportableCount: nonImportable.length, + ratio: total === 0 ? 0 : Math.round((importable.length / total) * 100) / 100, + nonImportable: nonImportable.map((o) => ({ + objectId: o.objectId, + reason: o.nonImportableReason ?? 'anledning ej angiven', + })), + }; +} + +// ----------------------------------------------------------------------------- +// T02: session resume +// ----------------------------------------------------------------------------- + +export interface ResumeDecision { + state: 'resumed' | 'fresh'; + checkpointCursor: string; + resolvedConflicts: ReadonlyArray; +} + +/** + * T02: resume a migration after a session change. The session record + * (checkpoint + decided conflicts) persists; a new session picks up EXACTLY + * where the old one left off. + */ +export function resumeMigration( + stored: MigrationSession | undefined, + requestingTenantId: string, +): ResumeDecision { + if (!stored || stored.tenantId !== requestingTenantId) { + return { state: 'fresh', checkpointCursor: '0', resolvedConflicts: [] }; + } + return { + state: 'resumed', + checkpointCursor: stored.checkpointCursor, + resolvedConflicts: [...stored.resolvedConflicts], + }; +} + +// ----------------------------------------------------------------------------- +// T03: scoped coverage claim +// ----------------------------------------------------------------------------- + +export interface CoverageClaim { + /** T03: the claim is SCOPED — what WAS imported, not a generic total. */ + scope: string; + importedCount: number; + /** The denominator INCLUDES non-importable objects (T01). */ + totalSourceObjects: number; + nonImportableCount: number; +} + +/** + * T03: the coverage claim is SCOPED (a defined scope, never a total generic + * badge). "100% CRM-listor" means CRM lists specifically — never "complete + * Microsoft 365 replacement". + */ +export function buildCoverageClaim( + scope: string, + ratio: ImportRatio, +): CoverageClaim { + return { + scope: `Migration coverage for '${scope}' — ${ratio.importableCount} av ${ratio.totalSourceObjects} objekt importerade (${ratio.nonImportableCount} ej importerbarta, redovisade)`, + importedCount: ratio.importableCount, + totalSourceObjects: ratio.totalSourceObjects, + nonImportableCount: ratio.nonImportableCount, + }; +} \ No newline at end of file diff --git a/tests/connectors/migration-studio.test.ts b/tests/connectors/migration-studio.test.ts new file mode 100644 index 0000000..4b59239 --- /dev/null +++ b/tests/connectors/migration-studio.test.ts @@ -0,0 +1,99 @@ +/** + * `migration-studio.test.ts` — HSP-085 acceptance: + * T01: an EJ importerbart objekt döljs inte ur nämnaren. + * T02: en uppgift kan återupptas efter sessionbyte. + * T03: en importerad CRM-lista marknadsförs inte som komplett M365-ersättning. + * Runner: vitest (0pt-konventionen). + */ +import { describe, expect, it } from 'vitest'; +import { + computeImportRatio, + resumeMigration, + buildCoverageClaim, + type MigrationSourceObject, + type MigrationSession, +} from '../../src/connectors/migration-studio'; + +function sourceSet(): MigrationSourceObject[] { + return [ + { objectId: 'obj_1', name: 'CRM-lista: kunder', importable: true, tenantId: 't_1' }, + { objectId: 'obj_2', name: 'CRM-lista: leads', importable: true, tenantId: 't_1' }, + { objectId: 'obj_3', name: 'M365 mail-box', importable: false, nonImportableReason: 'mail är utanför migrationsscopet (p0st äger mail)', tenantId: 't_1' }, + { objectId: 'obj_4', name: 'Cross-tenant doc', importable: false, nonImportableReason: 'annan tenant', tenantId: 't_other' }, + ]; +} + +describe('HSP-085-T01: ej importerbart objekt döljs inte ur nämnaren', () => { + it('the ratio counts EVERY source object in the denominator (non-importable stays)', () => { + const ratio = computeImportRatio(sourceSet()); + expect(ratio.totalSourceObjects).toBe(4); + expect(ratio.importableCount).toBe(2); + expect(ratio.nonImportableCount).toBe(2); + // 2/4 = 0.5 — NOT 2/2 = 1.0 (which would be hiding the failures). + expect(ratio.ratio).toBe(0.5); + }); + + it('the non-importable objects are ALWAYS listed with their reasons', () => { + const ratio = computeImportRatio(sourceSet()); + expect(ratio.nonImportable).toHaveLength(2); + expect(ratio.nonImportable.some((n) => n.reason.includes('mail'))).toBe(true); + expect(ratio.nonImportable.some((n) => n.reason.includes('annan tenant'))).toBe(true); + }); + + it('a fully-importable source gives ratio 1.0 (honest, no non-importable to hide)', () => { + const ratio = computeImportRatio([ + { objectId: 'a', name: 'a', importable: true, tenantId: 't' }, + ]); + expect(ratio.ratio).toBe(1); + expect(ratio.nonImportable).toEqual([]); + }); +}); + +describe('HSP-085-T02: uppgift kan återupptas efter sessionbyte', () => { + const stored: MigrationSession = { + migrationId: 'mig_1', + tenantId: 't_1', + checkpointCursor: 'batch_3', + resolvedConflicts: ['conflict_a', 'conflict_b'], + state: 'conflict_review', + }; + + it('a session change RESUMES from the stored checkpoint + resolved conflicts', () => { + const result = resumeMigration(stored, 't_1'); + expect(result.state).toBe('resumed'); + expect(result.checkpointCursor).toBe('batch_3'); + expect(result.resolvedConflicts).toEqual(['conflict_a', 'conflict_b']); + }); + + it('a session change to a DIFFERENT tenant starts FRESH (no cross-tenant resume)', () => { + const result = resumeMigration(stored, 't_other'); + expect(result.state).toBe('fresh'); + expect(result.checkpointCursor).toBe('0'); + }); + + it('no stored session starts fresh', () => { + const result = resumeMigration(undefined, 't_1'); + expect(result.state).toBe('fresh'); + expect(result.checkpointCursor).toBe('0'); + }); +}); + +describe('HSP-085-T03: CRM-lista marknadsförs inte som komplett M365-ersättning', () => { + it('the coverage claim is SCOPED to what was imported (never a generic total)', () => { + const ratio = computeImportRatio(sourceSet()); + const claim = buildCoverageClaim('CRM-listor', ratio); + expect(claim.scope).toContain('CRM-listor'); + expect(claim.scope).toContain('2 av 4'); + expect(claim.scope).toContain('ej importerbarta'); + // NEVER claims a generic total badge. + expect(claim.scope.toLowerCase()).not.toContain('complete'); + expect(claim.scope.toLowerCase()).not.toContain('komplett microsoft'); + }); + + it('the claim carries the NON-IMPORTABLE count (the honest denominator)', () => { + const ratio = computeImportRatio(sourceSet()); + const claim = buildCoverageClaim('CRM-listor', ratio); + expect(claim.nonImportableCount).toBe(2); + expect(claim.totalSourceObjects).toBe(4); + }); +}); \ No newline at end of file