feat(HSP-085): Migration Studio — ratio, session-resume, scoped coverage claim #99

Merged
admin merged 1 commits from feat/hsp-085-migration-studio into main 2026-09-22 20:23:12 +00:00
2 changed files with 233 additions and 0 deletions
+134
View File
@@ -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<string>;
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<MigrationSourceObject>): 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<string>;
}
/**
* 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,
};
}
+99
View File
@@ -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);
});
});