fix: Paket 0 – data integrity, security hardening and field usability
Backend Image / build-and-push (push) Successful in 3m15s
Frontend Image / build-and-push (push) Successful in 6m2s

Backend
- Backup covers every Prisma model (bundle v3); a guard test fails for any
  new model that is neither backed up nor explicitly excluded. Unsealed
  bundles may only add rows to 27 legacy master/field tables and never
  overwrite; counters, ledgers, settings, roles etc. need a sealed file.
  Shared import limit BACKUP_MAX_IMPORT_BYTES (default 50 MB, max 256 MB)
  with warnings in log and UI; replaced document files are removed after
  commit.
- Post-commit side effects (automation, calendar sync, webhooks) run via
  runAfterCommit and never turn a committed write into a 500, so retries
  with the same Idempotency-Key no longer create duplicates.
- Calendar outbox is multi-replica safe: per-entry claims with stale
  takeover, revision-based settle, new Graph event only on 404/410,
  transactionId on create, 30 s Graph timeout, URL-encoded ids, GUID
  validation for groupId, "run now" in background and resets FAILED.
- Automation tasks are deduplicated under a row lock on the rule.
- Update DTOs follow one null rule: omitted = unchanged, null clears a
  nullable field, null on a required field is a 400.
- Varroa mites/day is recomputed on correction; withdrawal status is
  checked against the harvest date (?at=), bounded colonyIds; inspection
  create is audited; updates re-enqueue calendar entries.
- Dashboard, statistics and apiary/colony guards treat WEAK, CONSPICUOUS
  and QUEENLESS colonies as living.
- Prisma errors and body-parser errors are logged without query values or
  request bodies.
- Migration 20261012100000_calendar_sync_claims; schema index name aligned
  with the existing database object.

Frontend
- Sticky save bar sticks again (no scroll container ancestors, card uses
  overflow-clip), safe-area insets, scroll-padding, FAB hidden on forms,
  sticky desktop sidebar.
- Inspections record yes / no / not checked instead of silent defaults.
- All living colonies are selectable; shared RecordSelect, EnumSelect,
  ChoiceGroup and TriStateField replace per-page copies; selects show
  names instead of ids; labels point at real controls.
- Emptied optional fields are sent as null (emptyToNull) so every field
  can be cleared; dates default to the local day and are sent via
  toApiDate.
- Harvest withdrawal warning uses the harvest date; backup page warns when
  a backup outgrows the import limit and lists skipped tables.

Build and docs
- Docker builds fail unless lint, typecheck and unit tests pass.
- ARCHITECTURE.md updated (assumptions 9-12, 19-21, section 8) and spec
  docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md
  for the backbone (api/worker/job roles, domain event outbox) and the
  feature roadmap T3-T7.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 16:38:55 +02:00
1 parent f5b1964223
commit 10cbe9bf2d
310 files changed
+12177 -3746

No files matched your search

+602 -25
View File
@@ -1,5 +1,11 @@
import { BadRequestException } from '@nestjs/common';
import { BackupService } from './backup.service';
import { BadRequestException, Logger } from '@nestjs/common';
import * as path from 'node:path';
import { Prisma } from '../../generated/prisma';
import {
BackupService,
LEGACY_UNSEALED_TABLES,
MODELS_EXCLUDED_FROM_BACKUP,
} from './backup.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
@@ -8,6 +14,7 @@ jest.mock('node:fs/promises', () => ({
readFile: jest.fn(),
writeFile: jest.fn(),
mkdir: jest.fn(),
unlink: jest.fn(),
}));
import * as fs from 'node:fs/promises';
@@ -36,15 +43,25 @@ const BUSINESS_ENTITY_KEYS = [
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'honeyBatchContainerAllocation',
'stockItem',
'stockMovement',
'product',
'labelTemplate',
'bottlingBatch',
'rawMaterialLot',
'productionBatch',
'productionInput',
'shopStockRequest',
'batchMovement',
'equipment',
'webhookSubscription',
'automationRule',
'automationSequence',
'siteSettings',
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
] as const;
const ALL_ENTITY_KEYS = [
@@ -57,17 +74,30 @@ const ALL_ENTITY_KEYS = [
'auditLog',
] as const;
// Prisma delegate name of a model: `productionBatch` for ProductionBatch.
function modelKey(model: string): string {
return model.charAt(0).toLowerCase() + model.slice(1);
}
// Every model the schema has - so a table added later is mocked too and the
// coverage guard below reports it instead of crashing on a missing mock.
const MODEL_KEYS = Object.values(Prisma.ModelName).map(modelKey);
type Row = Record<string, unknown>;
describe('BackupService', () => {
let service: BackupService;
let prisma: Record<string, { findMany: jest.Mock }> & {
user: { findMany: jest.Mock };
rolePermission: { findMany: jest.Mock };
document: { findMany: jest.Mock };
document: { findMany: jest.Mock; count: jest.Mock };
$transaction: jest.Mock;
};
let tx: Record<string, { upsert: jest.Mock; update?: jest.Mock }> & {
permission: { findUnique: jest.Mock };
user: { upsert: jest.Mock; findMany: jest.Mock };
queen: { upsert: jest.Mock; update: jest.Mock; findMany: jest.Mock };
document: { upsert: jest.Mock; findUnique: jest.Mock };
};
let auditLog: { record: jest.Mock };
let permissionResolver: { invalidateAll: jest.Mock };
@@ -77,20 +107,14 @@ describe('BackupService', () => {
jest.clearAllMocks();
tx = {} as never;
for (const key of [
...BUSINESS_ENTITY_KEYS,
'role',
'user',
'rolePermission',
'document',
'notification',
'auditLog',
]) {
for (const key of MODEL_KEYS) {
tx[key] = {
upsert: jest.fn().mockResolvedValue({ id: `${key}-upserted` }),
};
}
tx.queen.update = jest.fn().mockResolvedValue({});
tx.queen.findMany = jest.fn().mockResolvedValue([]);
tx.document.findUnique = jest.fn().mockResolvedValue(null);
tx.permission = {
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
};
@@ -100,23 +124,14 @@ describe('BackupService', () => {
.mockResolvedValue([]);
prisma = {
user: {
findMany: jest.fn().mockResolvedValue([]),
},
rolePermission: { findMany: jest.fn().mockResolvedValue([]) },
document: { findMany: jest.fn().mockResolvedValue([]) },
$transaction: jest.fn((callback: (txArg: typeof tx) => unknown) =>
callback(tx),
),
} as never;
for (const key of [
'role',
...BUSINESS_ENTITY_KEYS,
'notification',
'auditLog',
]) {
for (const key of MODEL_KEYS) {
prisma[key] = { findMany: jest.fn().mockResolvedValue([]) };
}
prisma.document.count = jest.fn().mockResolvedValue(0);
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
@@ -129,10 +144,10 @@ describe('BackupService', () => {
});
describe('exportAll', () => {
it('returns a version 2 bundle with every entity key, including role/user/document', async () => {
it('returns a version 3 bundle with every entity key, including role/user/document', async () => {
const bundle = await service.exportAll();
expect(bundle.version).toBe(2);
expect(bundle.version).toBe(3);
expect(typeof bundle.exportedAt).toBe('string');
for (const key of ALL_ENTITY_KEYS) {
expect(bundle.data).toHaveProperty(key);
@@ -589,5 +604,567 @@ describe('BackupService', () => {
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
);
});
it('restores production, raw material and stock ledger rows by id, remapping their optional userId', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
rawMaterialLot: [{ id: 'lot-1', userId: 'ghost-user' }],
productionBatch: [
{ id: 'batch-1', productId: 'product-1', userId: 'known-user' },
],
productionInput: [
{
id: 'input-1',
productionBatchId: 'batch-1',
rawMaterialLotId: 'lot-1',
},
],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
batchMovement: [
{
id: 'movement-1',
productionBatchId: 'batch-1',
shopRequestId: 'request-1',
userId: null,
},
],
honeyBatchContainerAllocation: [
{
id: 'allocation-1',
honeyBatchId: 'honey-1',
storageContainerId: 'container-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const firstCall = (key: string) =>
(
tx[key].upsert.mock.calls as [
{
where: Record<string, unknown>;
create: Record<string, unknown>;
},
][]
)[0][0];
expect(firstCall('rawMaterialLot').where).toEqual({ id: 'lot-1' });
expect(firstCall('rawMaterialLot').create.userId).toBe('admin-1');
expect(firstCall('productionBatch').create.userId).toBe('known-user');
expect(firstCall('batchMovement').create.userId).toBeNull();
expect(firstCall('productionInput').where).toEqual({ id: 'input-1' });
expect(firstCall('shopStockRequest').where).toEqual({ id: 'request-1' });
expect(firstCall('honeyBatchContainerAllocation').where).toEqual({
id: 'allocation-1',
});
});
it('writes every parent row before the rows that reference it', async () => {
const one = (id: string) => [{ id }];
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: one('apiary-1'),
harvest: one('harvest-1'),
storageContainer: one('container-1'),
honeyBatch: one('honey-1'),
honeyBatchContainerAllocation: one('allocation-1'),
product: one('product-1'),
bottlingBatch: one('bottling-1'),
rawMaterialLot: one('lot-1'),
productionBatch: one('batch-1'),
productionInput: one('input-1'),
shopStockRequest: one('request-1'),
batchMovement: one('movement-1'),
automationRule: [{ triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT' }],
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const order = (key: string) => tx[key].upsert.mock.invocationCallOrder[0];
const dependencies: [parent: string, child: string][] = [
['storageContainer', 'honeyBatchContainerAllocation'],
['honeyBatch', 'honeyBatchContainerAllocation'],
['apiary', 'rawMaterialLot'],
['harvest', 'rawMaterialLot'],
['product', 'productionBatch'],
['productionBatch', 'productionInput'],
['rawMaterialLot', 'productionInput'],
['bottlingBatch', 'batchMovement'],
['productionBatch', 'batchMovement'],
['rawMaterialLot', 'batchMovement'],
['shopStockRequest', 'batchMovement'],
['automationRule', 'automationSequence'],
];
for (const [parent, child] of dependencies) {
expect({ parent, child, before: order(parent) < order(child) }).toEqual(
{ parent, child, before: true },
);
}
});
it('upserts automationSequence by triggerType and scope', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
automationSequence: [
{
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
value: 7,
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.automationSequence.upsert.mock.calls as [
[{ where: unknown; create: Record<string, unknown> }],
];
expect(call.where).toEqual({
triggerType_scope: {
triggerType: 'RAW_MATERIAL_LOT_NUMBER_FORMAT',
scope: 'BEESWAX',
},
});
expect(call.create.value).toBe(7);
});
it('upserts a calendar outbox entry by its source and keeps the target row id', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
calendarSyncOutboxEntry: [
{
id: 'entry-source-id',
sourceType: 'TASK',
sourceId: 'task-1',
graphEventId: 'graph-event-1',
},
],
},
},
'admin-1',
TRUSTED,
);
const [[call]] = tx.calendarSyncOutboxEntry.upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({
sourceType_sourceId: { sourceType: 'TASK', sourceId: 'task-1' },
});
expect(call.update).not.toHaveProperty('id');
expect(call.update.graphEventId).toBe('graph-event-1');
});
it('upserts the backup and calendar settings singletons by id without rewriting it', async () => {
await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 30 }],
calendarSyncSettings: [{ id: 'singleton', enabled: true }],
},
},
'admin-1',
TRUSTED,
);
for (const key of ['backupSettings', 'calendarSyncSettings']) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ where: unknown; update: Record<string, unknown> }],
];
expect(call.where).toEqual({ id: 'singleton' });
expect(call.update).not.toHaveProperty('id');
}
});
it('takes no calendar sync or backup schedule from an unsealed file', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
backupSettings: [{ id: 'singleton', retentionCount: 1 }],
calendarSyncSettings: [{ id: 'singleton', groupId: 'evil' }],
calendarSyncOutboxEntry: [
{ id: 'entry-1', sourceType: 'TASK', sourceId: 'task-1' },
],
},
},
'admin-1',
{ trusted: false },
);
expect(tx.backupSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncSettings.upsert).not.toHaveBeenCalled();
expect(tx.calendarSyncOutboxEntry.upsert).not.toHaveBeenCalled();
expect(summary.skipped).toEqual(
expect.arrayContaining([
'backupSettings',
'calendarSyncSettings',
'calendarSyncOutboxEntry',
]),
);
});
it('restores an older bundle that lacks the newer tables as if they were empty', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
},
'admin-1',
TRUSTED,
);
expect(summary.imported).toEqual(
expect.objectContaining({
apiary: 1,
productionBatch: 0,
rawMaterialLot: 0,
batchMovement: 0,
automationSequence: 0,
}),
);
});
});
// A file without the seal cannot be proven to come from ApiCore: it may
// only add plain master and field records, nothing else.
describe('unsealed file', () => {
const UNSEALED = { trusted: false };
const SEALED_ONLY_SAMPLE: Record<string, Row[]> = {
automationRule: [{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT' }],
automationSequence: [
{ triggerType: 'HONEY_BATCH_NUMBER_FORMAT', scope: 'x', value: 0 },
],
stockItem: [{ id: 'stock-1' }],
stockMovement: [{ id: 'movement-1' }],
batchMovement: [{ id: 'ledger-1' }],
shopStockRequest: [{ id: 'request-1', externalId: 'order-1' }],
rawMaterialLot: [{ id: 'lot-1' }],
productionBatch: [{ id: 'batch-1' }],
productionInput: [{ id: 'input-1' }],
honeyBatchContainerAllocation: [{ id: 'allocation-1' }],
siteSettings: [{ id: 'singleton', holderName: 'Fake' }],
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
},
],
notification: [{ id: 'note-1', message: 'Bitte hier anmelden' }],
};
it('takes no counters, ledgers, shop requests, settings, documents or notifications from it', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
...SEALED_ONLY_SAMPLE,
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
},
},
'admin-1',
UNSEALED,
);
for (const key of Object.keys(SEALED_ONLY_SAMPLE)) {
expect({ key, calls: tx[key].upsert.mock.calls.length }).toEqual({
key,
calls: 0,
});
}
expect(fs.writeFile).not.toHaveBeenCalled();
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
expect([...summary.skipped].sort()).toEqual(
Object.keys(SEALED_ONLY_SAMPLE).sort(),
);
});
it('allows only plain master and field records - no table added later', () => {
expect([...LEGACY_UNSEALED_TABLES].sort()).toEqual(
[
'apiary',
'queen',
'colony',
'colonyApiaryHistory',
'colonyLineage',
'queenEvaluation',
'queenColonyHistory',
'inspection',
'taskTemplate',
'task',
'varroaMeasurement',
'swarmControlCheck',
'winterCheck',
'treatment',
'feeding',
'harvest',
'harvestColony',
'extraction',
'extractionHarvest',
'storageContainer',
'honeyBatch',
'honeyMeasurement',
'honeyBatchRelocation',
'product',
'labelTemplate',
'bottlingBatch',
'equipment',
].sort(),
);
});
it('adds the records it carries but never rewrites ones that already exist', async () => {
const data = Object.fromEntries(
LEGACY_UNSEALED_TABLES.map((key) => [
key,
[{ id: `${key}-1`, notes: 'aus der Datei' }],
]),
);
await service.importAll(
{ version: 2, exportedAt: '2026-01-01T00:00:00.000Z', data },
'admin-1',
UNSEALED,
);
for (const key of LEGACY_UNSEALED_TABLES) {
const [[call]] = tx[key].upsert.mock.calls as [
[{ create: Row; update: Row }],
];
expect({ key, update: call.update }).toEqual({ key, update: {} });
expect(call.create.notes).toBe('aus der Datei');
}
});
it('does not re-link the mother line of a queen that was already there', async () => {
tx.queen.findMany.mockResolvedValue([{ id: 'queen-existing' }]);
await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
queen: [
{ id: 'queen-existing', motherQueenId: 'queen-mother' },
{ id: 'queen-new', motherQueenId: 'queen-mother' },
{ id: 'queen-mother', motherQueenId: null },
],
},
},
'admin-1',
UNSEALED,
);
expect(tx.queen.update).toHaveBeenCalledTimes(1);
expect(tx.queen.update).toHaveBeenCalledWith({
where: { id: 'queen-new' },
data: { motherQueenId: 'queen-mother' },
});
});
it('reports the import as unsealed and lists no unknown sections as skipped', async () => {
const summary = await service.importAll(
{
version: 2,
exportedAt: '2026-01-01T00:00:00.000Z',
data: { notATable: [{ id: 'x' }], apiary: [{ id: 'apiary-1' }] },
},
'admin-1',
UNSEALED,
);
expect(summary.sealed).toBe(false);
expect(summary.skipped).toEqual([]);
});
it('still restores everything, overwriting existing rows, from a sealed file', async () => {
const summary = await service.importAll(
{
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
stockMovement: [{ id: 'movement-1', userId: null }],
},
},
'admin-1',
TRUSTED,
);
expect(summary.sealed).toBe(true);
expect(summary.skipped).toEqual([]);
const [[call]] = tx.apiary.upsert.mock.calls as [[{ update: Row }]];
expect(call.update).toEqual({ id: 'apiary-1', name: 'Stand Wald' });
expect(tx.stockMovement.upsert).toHaveBeenCalledTimes(1);
});
});
// Restoring a document writes its content to a new file; the file the row
// pointed at before must not be left behind on disk.
describe('replaced document files', () => {
const storageRoot = path.resolve(
process.env.DOCUMENTS_STORAGE_PATH ?? './uploads',
);
const oldFile = path.join(storageRoot, 'colony', 'old.pdf');
const bundle = {
version: 3,
exportedAt: '2026-01-01T00:00:00.000Z',
data: {
document: [
{
id: 'doc-1',
entityType: 'COLONY',
mimeType: 'application/pdf',
contentBase64: Buffer.from('%PDF-1.4 new').toString('base64'),
},
],
},
};
let warn: jest.SpyInstance;
beforeEach(() => {
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
(fs.mkdir as jest.Mock).mockResolvedValue(undefined);
(fs.writeFile as jest.Mock).mockResolvedValue(undefined);
(fs.unlink as jest.Mock).mockResolvedValue(undefined);
});
afterEach(() => {
warn.mockRestore();
});
it('deletes the file a restored row replaced, only after the import committed', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).toHaveBeenCalledWith(oldFile);
expect(
(fs.unlink as jest.Mock).mock.invocationCallOrder[0],
).toBeGreaterThan(tx.document.upsert.mock.invocationCallOrder[0]);
expect(prisma.document.count).toHaveBeenCalledWith({
where: { storagePath: oldFile },
});
});
it('deletes nothing when the import fails', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
tx.notification.upsert.mockRejectedValue(new Error('db down'));
await expect(
service.importAll(
{
...bundle,
data: { ...bundle.data, notification: [{ id: 'note-1' }] },
},
'admin-1',
TRUSTED,
),
).rejects.toThrow('db down');
expect(fs.unlink).not.toHaveBeenCalled();
});
it('keeps a replaced file outside the documents root and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: '/etc/passwd' });
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledTimes(1);
});
it('keeps a replaced file that another document row still uses', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
prisma.document.count.mockResolvedValue(1);
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
it('completes the import when a replaced file cannot be deleted, and logs it', async () => {
tx.document.findUnique.mockResolvedValue({ storagePath: oldFile });
(fs.unlink as jest.Mock).mockRejectedValue(new Error('EBUSY'));
await expect(
service.importAll(bundle, 'admin-1', TRUSTED),
).resolves.toEqual(expect.objectContaining({ sealed: true }));
expect(warn).toHaveBeenCalledTimes(1);
});
it('deletes nothing for a document that is new to this database', async () => {
await service.importAll(bundle, 'admin-1', TRUSTED);
expect(fs.unlink).not.toHaveBeenCalled();
});
});
// Drift guard: a model added to the schema has to be backed up or listed
// in MODELS_EXCLUDED_FROM_BACKUP with its reason - never silently missing.
describe('model coverage', () => {
it('backs up every Prisma model or names why it is left out', async () => {
const bundle = await service.exportAll();
const uncovered = Object.values(Prisma.ModelName).filter(
(model) =>
!(modelKey(model) in bundle.data) &&
!(model in MODELS_EXCLUDED_FROM_BACKUP),
);
expect(uncovered).toEqual([]);
});
it('gives every excluded model a reason and never exports it anyway', async () => {
const bundle = await service.exportAll();
for (const [model, reason] of Object.entries(
MODELS_EXCLUDED_FROM_BACKUP,
)) {
expect(bundle.data).not.toHaveProperty(modelKey(model));
expect(reason?.trim().length).toBeGreaterThan(0);
}
});
it('restores every table it exports', async () => {
const bundle = await service.exportAll();
const summary = await service.importAll(bundle, 'admin-1', TRUSTED);
expect(Object.keys(summary.imported).sort()).toEqual(
Object.keys(bundle.data).sort(),
);
});
});
});