feat(HSP-085): Migration Studio — ratio, session-resume, scoped coverage claim #99
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user