test(17-01): prove multi-mailbox fan-out and the userId migration's SQL order
Der mandantenuebergreifende Sammelabruf in email-alert.adapter.ts bleibt
mechanisch unveraendert (findMany({isActive:true}) in einem Zug,
Fehlerbehandlung je Zeile) — geaendert wird nur die Warnmeldung (Zeilen-id
+ Besitzer statt Mandant, T-17-03) und die Klassendoku.
- Neue Tests: zwei aktive Postfaecher DESSELBEN Mandanten werden beide mit
ihren jeweils eigenen Zugangsdaten abgeholt; ein kaputtes Postfach
blockiert das andere nicht und protokolliert eine Warnung ohne
Zugangsdaten/Adresse; die Herkunftsmarkierung folgt dem Mandantenfeld
der jeweiligen Zeile (zwei Mandanten -> zwei Werte). Erwartungswerte von
Hand geschrieben, nicht ueber die Produktivfunktion erzeugt.
- tender-email-config.service.spec.ts (bereits in der Task-2-Migration
mitgeliefert) deckt zusaetzlich: tenantId wird beim Anlegen mitgeschrieben,
zwei Nutzer desselben Mandanten erzeugen zwei Zeilen statt eine zu
ueberschreiben.
- email-config-migration-sql.spec.ts (neu, Vorbild
doe-url-migration-sql.spec.ts): prueft die Reihenfolge der
Hand-Migration textuell — Zuordnung vor Loeschung, Pflicht erst nach
Befuellung, alte Eindeutigkeit runter/neue rauf, gewoehnlicher
tenantId-Index bleibt stehen.
src/tenders: 335/335 gruen. API gesamt: 603/603. Web gesamt: 192/192.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { readFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import {
|
||||
EmailAlertAdapter,
|
||||
@@ -196,6 +197,8 @@ describe('EmailAlertAdapter', () => {
|
||||
const { adapter } = makeAdapter({
|
||||
configs: [
|
||||
{
|
||||
id: 'cfg-a',
|
||||
userId: 'user-a',
|
||||
tenantId: 'tenant-a',
|
||||
protocol: 'imap',
|
||||
host: 'imap.example.test',
|
||||
@@ -225,6 +228,8 @@ describe('EmailAlertAdapter', () => {
|
||||
const { adapter } = makeAdapter({
|
||||
configs: [
|
||||
{
|
||||
id: 'cfg-ews',
|
||||
userId: 'user-ews',
|
||||
tenantId: 'tenant-ews',
|
||||
protocol: 'exchange',
|
||||
host: 'https://mail.example.test/EWS/Exchange.asmx',
|
||||
@@ -258,6 +263,8 @@ describe('EmailAlertAdapter', () => {
|
||||
tenderEmailConfig: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'cfg-a',
|
||||
userId: 'user-a',
|
||||
tenantId: 'tenant-a',
|
||||
protocol: 'imap',
|
||||
host: 'imap.example.test',
|
||||
@@ -285,7 +292,7 @@ describe('EmailAlertAdapter', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('catch-per-tenant: one mocked mailbox throws, others still return records tagged with their own ownerTenantId', async () => {
|
||||
it('catch-per-mailbox: one of two mailboxes throws, the other still returns its records, and a warning is logged naming only the row id + owner (T-17-03)', async () => {
|
||||
const throwingFetch = vi.fn(async () => {
|
||||
throw new Error('connection refused');
|
||||
});
|
||||
@@ -295,6 +302,8 @@ describe('EmailAlertAdapter', () => {
|
||||
tenderEmailConfig: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'cfg-broken',
|
||||
userId: 'user-broken',
|
||||
tenantId: 'tenant-broken',
|
||||
protocol: 'imap',
|
||||
host: 'imap.broken.test',
|
||||
@@ -306,6 +315,8 @@ describe('EmailAlertAdapter', () => {
|
||||
encryptedInboxCreds: null,
|
||||
},
|
||||
{
|
||||
id: 'cfg-ok',
|
||||
userId: 'user-ok',
|
||||
tenantId: 'tenant-ok',
|
||||
protocol: 'imap',
|
||||
host: 'imap.ok.test',
|
||||
@@ -320,13 +331,14 @@ describe('EmailAlertAdapter', () => {
|
||||
},
|
||||
};
|
||||
// First mailbox throws, second succeeds — same imapProvider instance,
|
||||
// called twice (once per tenant), mockImplementationOnce per call.
|
||||
// called twice (once per mailbox row), mockImplementationOnce per call.
|
||||
const imapProvider = {
|
||||
fetchMessages: vi
|
||||
.fn()
|
||||
.mockImplementationOnce(throwingFetch)
|
||||
.mockImplementationOnce(workingFetch),
|
||||
};
|
||||
const warnSpy = vi.spyOn(Logger.prototype, 'warn').mockImplementation(() => undefined);
|
||||
const adapter = new EmailAlertAdapter(
|
||||
prisma as any,
|
||||
crypto as any,
|
||||
@@ -339,6 +351,145 @@ describe('EmailAlertAdapter', () => {
|
||||
expect(imapProvider.fetchMessages).toHaveBeenCalledTimes(2);
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.ownerTenantId).toBe('tenant-ok');
|
||||
// Hand-written expected message (T-17-03) — not built via the same
|
||||
// template string the production code uses, so this actually proves
|
||||
// the message shape rather than restating the implementation.
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
'Email-alert mailbox cfg-broken (owner user-broken) failed, skipping: connection refused',
|
||||
);
|
||||
// Never leaks credentials/mailbox address into the log line.
|
||||
const loggedMessages = warnSpy.mock.calls.map((call) => String(call[0]));
|
||||
expect(loggedMessages.join(' ')).not.toMatch(/imap\.broken\.test|password|username/i);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('two active rows of the SAME tenant, different owners and different mailbox data: the provider is called twice with each owner\'s own credentials, and the result contains candidates from both (Phase 17, D-01)', async () => {
|
||||
const msgFromA = { ...MSG, subject: 'Ausschreibung aus Postfach A' };
|
||||
const msgFromB = { ...MSG, subject: 'Ausschreibung aus Postfach B' };
|
||||
const cryptoReal = makeFakeCrypto();
|
||||
const credsA = cryptoReal.encrypt(
|
||||
JSON.stringify({ username: 'user-a@example.test', password: 'secret-a' }),
|
||||
);
|
||||
const credsB = cryptoReal.encrypt(
|
||||
JSON.stringify({ username: 'user-b@example.test', password: 'secret-b' }),
|
||||
);
|
||||
const imapFetchMessages = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [msgFromA])
|
||||
.mockImplementationOnce(async () => [msgFromB]);
|
||||
const prisma = {
|
||||
tenderEmailConfig: {
|
||||
findMany: vi.fn(async () => [
|
||||
{
|
||||
id: 'cfg-user-a',
|
||||
userId: 'user-a',
|
||||
tenantId: 'tenant-shared',
|
||||
protocol: 'imap',
|
||||
host: 'imap.user-a.test',
|
||||
port: 993,
|
||||
encryption: 'ssl-tls',
|
||||
folder: 'INBOX',
|
||||
senderFilter: null,
|
||||
domain: null,
|
||||
encryptedInboxCreds: credsA,
|
||||
},
|
||||
{
|
||||
id: 'cfg-user-b',
|
||||
userId: 'user-b',
|
||||
tenantId: 'tenant-shared',
|
||||
protocol: 'imap',
|
||||
host: 'imap.user-b.test',
|
||||
port: 993,
|
||||
encryption: 'ssl-tls',
|
||||
folder: 'INBOX',
|
||||
senderFilter: null,
|
||||
domain: null,
|
||||
encryptedInboxCreds: credsB,
|
||||
},
|
||||
]),
|
||||
},
|
||||
};
|
||||
const adapter = new EmailAlertAdapter(
|
||||
prisma as any,
|
||||
cryptoReal as any,
|
||||
{ fetchMessages: imapFetchMessages } as any,
|
||||
{ fetchMessages: vi.fn(async () => []) } as any,
|
||||
);
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
expect(imapFetchMessages).toHaveBeenCalledTimes(2);
|
||||
expect(imapFetchMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
host: 'imap.user-a.test',
|
||||
username: 'user-a@example.test',
|
||||
password: 'secret-a',
|
||||
}),
|
||||
);
|
||||
expect(imapFetchMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
host: 'imap.user-b.test',
|
||||
username: 'user-b@example.test',
|
||||
password: 'secret-b',
|
||||
}),
|
||||
);
|
||||
const titles = records.map((r) => (r.ocdsPayload as { title: string }).title);
|
||||
expect(titles).toContain('Ausschreibung aus Postfach A');
|
||||
expect(titles).toContain('Ausschreibung aus Postfach B');
|
||||
expect(records.every((r) => r.ownerTenantId === 'tenant-shared')).toBe(true);
|
||||
});
|
||||
|
||||
it('ownerTenantId on each extracted record equals that row\'s tenantId — two rows of different tenants yield two different values (D-13)', async () => {
|
||||
const msgFromTenant1 = { ...MSG, subject: 'Von Mandant 1' };
|
||||
const msgFromTenant2 = { ...MSG, subject: 'Von Mandant 2' };
|
||||
const imapFetchMessages = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(async () => [msgFromTenant1])
|
||||
.mockImplementationOnce(async () => [msgFromTenant2]);
|
||||
const { adapter } = makeAdapter({
|
||||
configs: [
|
||||
{
|
||||
id: 'cfg-t1',
|
||||
userId: 'user-t1',
|
||||
tenantId: 'tenant-1',
|
||||
protocol: 'imap',
|
||||
host: 'imap.tenant1.test',
|
||||
port: 993,
|
||||
encryption: 'ssl-tls',
|
||||
folder: 'INBOX',
|
||||
senderFilter: null,
|
||||
domain: null,
|
||||
encryptedInboxCreds: null,
|
||||
},
|
||||
{
|
||||
id: 'cfg-t2',
|
||||
userId: 'user-t2',
|
||||
tenantId: 'tenant-2',
|
||||
protocol: 'imap',
|
||||
host: 'imap.tenant2.test',
|
||||
port: 993,
|
||||
encryption: 'ssl-tls',
|
||||
folder: 'INBOX',
|
||||
senderFilter: null,
|
||||
domain: null,
|
||||
encryptedInboxCreds: null,
|
||||
},
|
||||
],
|
||||
imapFetchMessages,
|
||||
});
|
||||
|
||||
const records = await adapter.fetchTenders('2026-07-23');
|
||||
|
||||
const byTitle = (title: string) =>
|
||||
records.find((r) => (r.ocdsPayload as { title: string }).title === title);
|
||||
|
||||
expect(byTitle('Von Mandant 1')?.ownerTenantId).toBe('tenant-1');
|
||||
expect(byTitle('Von Mandant 2')?.ownerTenantId).toBe('tenant-2');
|
||||
expect(byTitle('Von Mandant 1')?.ownerTenantId).not.toBe(
|
||||
byTitle('Von Mandant 2')?.ownerTenantId,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns [] when there are no active configs', async () => {
|
||||
@@ -356,6 +507,8 @@ describe('EmailAlertAdapter', () => {
|
||||
const { adapter } = makeAdapter({
|
||||
configs: [
|
||||
{
|
||||
id: 'cfg-a',
|
||||
userId: 'user-a',
|
||||
tenantId: 'tenant-a',
|
||||
protocol: 'imap',
|
||||
host: 'imap.example.test',
|
||||
|
||||
@@ -12,25 +12,31 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||
|
||||
/**
|
||||
* EmailAlertAdapter — INGEST-05. Extracts candidate tender detail links and
|
||||
* a display title from a tenant's portal-alert mailbox, generically (D-04):
|
||||
* no per-portal parser, since no concrete alert-sending portals are known
|
||||
* a display title from a portal-alert mailbox, generically (D-04): no
|
||||
* per-portal parser, since no concrete alert-sending portals are known
|
||||
* ahead of time (14-CONTEXT.md). Feld-Armut (no CPV/buyer/deadline) is a
|
||||
* deliberate, documented consequence (D-05) — the same deferral already
|
||||
* accepted for the NetServer/cosinex/RSS scraper adapters.
|
||||
*
|
||||
* `fetchTenders()` (Plan 14-03 Task 2) is an internal per-tenant fan-out
|
||||
* (RESEARCH.md Pattern 1, same shape as NetServerAdapter's per-portal loop
|
||||
* and RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig`
|
||||
* row across ALL tenants in ONE query — this is a DELIBERATE, audited
|
||||
* `fetchTenders()` (Plan 14-03 Task 2) is an internal fan-out (RESEARCH.md
|
||||
* Pattern 1, same shape as NetServerAdapter's per-portal loop and
|
||||
* RssAdapter's per-feed loop): reads every ACTIVE `TenderEmailConfig` row
|
||||
* across ALL tenants in ONE query — this is a DELIBERATE, audited
|
||||
* cross-tenant read at the platform-scheduler level (mirrors the same
|
||||
* documented exception for RssAdapter/TenderIngestionService itself) and
|
||||
* must NEVER be wrapped in `forTenant()`/RLS. Each tenant's mailbox is
|
||||
* fetched independently via the shared `inbox/` module's
|
||||
* `InboxProvider.fetchMessages` (Plan 14-01) — catch-per-tenant, so one
|
||||
* tenant's broken/unreachable mailbox never blocks the others in the same
|
||||
* tick. Every extracted candidate is tagged with `ownerTenantId` = the
|
||||
* configuring tenant's id (D-13) so the resulting Tender rows stay private
|
||||
* to that tenant on the read side (tender-query.builder.ts).
|
||||
* must NEVER be wrapped in `forTenant()`/RLS. Since Phase 17 (Plan 01,
|
||||
* D-01) `TenderEmailConfig` ownership moved from tenant to user — a tenant
|
||||
* can now have MULTIPLE active mailboxes (one per user) — so the fan-out
|
||||
* runs per MAILBOX ROW, not per tenant; the mechanics are unchanged, this
|
||||
* is still the platform scheduler resolving every active row in one tick,
|
||||
* not a per-tenant/per-user request. Each mailbox is fetched independently
|
||||
* via the shared `inbox/` module's `InboxProvider.fetchMessages` (Plan
|
||||
* 14-01) — catch-per-mailbox, so one broken/unreachable mailbox never
|
||||
* blocks the others in the same tick, even when two of them belong to the
|
||||
* same tenant. Every extracted candidate is tagged with `ownerTenantId` =
|
||||
* that mailbox row's `tenantId` (D-13, still denormalized per-row after
|
||||
* D-01) so the resulting Tender rows stay private to that tenant on the
|
||||
* read side (tender-query.builder.ts).
|
||||
*
|
||||
* Security (T-14-03-03, stored-XSS guard): only plain-text `title` and raw
|
||||
* `href` URL strings are ever extracted from an alert email body — cheerio
|
||||
@@ -38,6 +44,8 @@ import type { TenderSourceAdapter } from './tender-source-adapter.interface';
|
||||
* markup ever crosses into a RawTenderRecord/Tender row.
|
||||
* Security (T-05-13): decrypted mailbox credentials only ever exist within
|
||||
* `resolveDecryptedConfig`'s return value scope — never logged.
|
||||
* Security (T-17-03): the per-mailbox failure warning below names only the
|
||||
* row id and owning userId — never username, password, or mailbox address.
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -139,7 +147,8 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||
async fetchTenders(_dayCursor: string): Promise<RawTenderRecord[]> {
|
||||
// Deliberate cross-tenant read (see class docstring) — NEVER wrap in
|
||||
// forTenant()/RLS. This is the platform scheduler resolving every
|
||||
// tenant's active mailbox in one tick, not a per-tenant request.
|
||||
// active mailbox row in one tick (potentially several per tenant since
|
||||
// Phase 17, D-01), not a per-tenant/per-user request.
|
||||
const configs = await this.prisma.tenderEmailConfig.findMany({
|
||||
where: { isActive: true },
|
||||
});
|
||||
@@ -155,10 +164,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||
const messages = await provider.fetchMessages(inboxConfig);
|
||||
records.push(...this.extractCandidates(messages, cfg.tenantId, fetchedAt));
|
||||
} catch (error) {
|
||||
// Catch-per-tenant (D-01 discipline, mirrors NetServer/RssAdapter):
|
||||
// one tenant's broken/unreachable mailbox never blocks the others.
|
||||
// Catch-per-mailbox (D-01 discipline, mirrors NetServer/RssAdapter):
|
||||
// one broken/unreachable mailbox never blocks the others — even
|
||||
// two mailboxes belonging to the same tenant (Phase 17, D-01).
|
||||
// T-17-03: names only the row id and owning userId, never
|
||||
// username/password/mailbox address.
|
||||
this.logger.warn(
|
||||
`Email-alert mailbox for tenant ${cfg.tenantId} failed, skipping: ${(error as Error).message}`,
|
||||
`Email-alert mailbox ${cfg.id} (owner ${cfg.userId}) failed, skipping: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -171,7 +183,7 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||
* the shared inbox providers. T-05-13: the decrypted password only ever
|
||||
* exists within this method's return value — never logged. A decrypt
|
||||
* failure resolves to no credentials (the provider's own auth failure is
|
||||
* then caught by fetchTenders' per-tenant try/catch above), matching the
|
||||
* then caught by fetchTenders' per-mailbox try/catch above), matching the
|
||||
* "ignore decrypt errors" convention already used by
|
||||
* DkvService/TenderEmailConfigService.
|
||||
*/
|
||||
@@ -206,10 +218,13 @@ export class EmailAlertAdapter implements TenderSourceAdapter {
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps one tenant's fetched InboxMessages into RawTenderRecord[] — one
|
||||
* record per extracted candidate link (D-04), every record tagged with
|
||||
* `ownerTenantId` (D-13) so the resulting Tender rows stay private to
|
||||
* this tenant on the read side.
|
||||
* Maps one mailbox row's fetched InboxMessages into RawTenderRecord[] —
|
||||
* one record per extracted candidate link (D-04), every record tagged
|
||||
* with `ownerTenantId` = that row's `tenantId` (D-13) so the resulting
|
||||
* Tender rows stay private to that tenant on the read side. Since Phase
|
||||
* 17 (D-01) a tenant can own several mailbox rows (one per user); each
|
||||
* still tags its records with the same `ownerTenantId` — visibility is
|
||||
* unchanged, only who configures the source differs.
|
||||
*/
|
||||
private extractCandidates(
|
||||
messages: InboxMessage[],
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Prüft das hand-geschriebene Migrations-SQL für den Wechsel von
|
||||
* TenderEmailConfig.tenantId auf userId (Phase 17, Plan 01, D-01) — reiner
|
||||
* Textabgleich ohne Datenbank, gleiches Muster wie
|
||||
* doe-url-migration-sql.spec.ts / groups/migration-sql.spec.ts.
|
||||
*
|
||||
* Die Reihenfolge ist die zentrale Korrektheitsbedingung dieser Migration:
|
||||
* eine bereits vorhandene Postfach-Zeile muss ihren Besitzer bekommen,
|
||||
* BEVOR unbesetzte Zeilen entfernt werden, und die Pflichtsetzung der
|
||||
* neuen Spalte darf erst NACH der Befüllung stehen — sonst schlägt die
|
||||
* Migration bei jeder Bestandszeile fehl.
|
||||
*/
|
||||
|
||||
const MIGRATIONS_DIR = join(__dirname, '../../prisma/migrations');
|
||||
|
||||
function readMigrationSql(suffix: string): string {
|
||||
const dirs = readdirSync(MIGRATIONS_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() && entry.name.endsWith(suffix))
|
||||
.map((entry) => entry.name);
|
||||
|
||||
if (dirs.length !== 1) {
|
||||
throw new Error(
|
||||
`Expected exactly one migration directory ending in "${suffix}", found ${dirs.length}: ${dirs.join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return readFileSync(join(MIGRATIONS_DIR, dirs[0], 'migration.sql'), 'utf-8');
|
||||
}
|
||||
|
||||
describe('tender_email_config_per_user migration.sql', () => {
|
||||
const sql = readMigrationSql('_tender_email_config_per_user');
|
||||
|
||||
it('ordnet Bestandszeilen einem Administrator zu, BEVOR unbesetzte Zeilen geloescht werden', () => {
|
||||
const updateIdx = sql.indexOf('UPDATE "TenderEmailConfig"');
|
||||
const deleteIdx = sql.indexOf('DELETE FROM "TenderEmailConfig"');
|
||||
|
||||
expect(updateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(deleteIdx).toBeGreaterThan(updateIdx);
|
||||
});
|
||||
|
||||
it('die Zuordnung beschraenkt sich auf aktive Nutzer mit Administratorrolle desselben Mandanten und nimmt genau einen', () => {
|
||||
const updateStmt = sql.slice(
|
||||
sql.indexOf('UPDATE "TenderEmailConfig"'),
|
||||
sql.indexOf('DELETE FROM "TenderEmailConfig"'),
|
||||
);
|
||||
|
||||
expect(updateStmt).toContain('u."tenantId" = tec."tenantId"');
|
||||
expect(updateStmt).toContain('u."isActive" = true');
|
||||
expect(updateStmt).toMatch(/u\."role"\s+IN\s+\('ADMIN',\s*'SUPER_ADMIN'\)/);
|
||||
expect(updateStmt).toMatch(/ORDER BY\s+u\."createdAt"\s+ASC,\s*u\."id"\s+ASC/);
|
||||
expect(updateStmt).toContain('LIMIT 1');
|
||||
});
|
||||
|
||||
it('entfernt nur Zeilen, die nach der Zuordnung noch keinen Besitzer haben (kein Administrator im Mandanten)', () => {
|
||||
expect(sql).toMatch(/DELETE FROM "TenderEmailConfig" WHERE "userId" IS NULL/);
|
||||
});
|
||||
|
||||
it('hebt die alte Eindeutigkeitsregel auf dem Mandantenfeld auf und legt eine neue auf dem Besitzerfeld an', () => {
|
||||
expect(sql).toContain('DROP INDEX "TenderEmailConfig_tenantId_key"');
|
||||
expect(sql).toContain(
|
||||
'CREATE UNIQUE INDEX "TenderEmailConfig_userId_key" ON "TenderEmailConfig"("userId")',
|
||||
);
|
||||
// Der gewoehnliche Index auf tenantId bleibt bestehen — tenantId wird
|
||||
// weiterhin gefiltert (SMTP-Aufloesung), nur die Eindeutigkeit faellt weg.
|
||||
expect(sql).not.toContain('DROP INDEX "TenderEmailConfig_tenantId_idx"');
|
||||
});
|
||||
|
||||
it('setzt die Spalte erst NACH der Befuellung auf Pflicht', () => {
|
||||
const backfillIdx = sql.indexOf('UPDATE "TenderEmailConfig"');
|
||||
const notNullIdx = sql.indexOf('ALTER COLUMN "userId" SET NOT NULL');
|
||||
|
||||
expect(backfillIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(notNullIdx).toBeGreaterThan(backfillIdx);
|
||||
});
|
||||
|
||||
it('legt die Spalte zunaechst ohne Pflicht an, bevor irgendetwas befuellt wird', () => {
|
||||
const addColumnIdx = sql.indexOf('ADD COLUMN "userId" TEXT');
|
||||
const backfillIdx = sql.indexOf('UPDATE "TenderEmailConfig"');
|
||||
|
||||
expect(addColumnIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(sql.slice(addColumnIdx, addColumnIdx + 40)).not.toMatch(/NOT NULL/);
|
||||
expect(backfillIdx).toBeGreaterThan(addColumnIdx);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user