Some checks failed
CD / Build and push images (push) Successful in 2m2s
CI / Lint, typecheck, test (push) Successful in 1m44s
CI / Auth e2e pack (push) Failing after 1m50s
CI / Build container images (push) Has been skipped
CD / Deploy to Test (push) Successful in 8s
CD / Smoke tests against Test (push) Successful in 1m8s
CD / Promote to Int (push) Successful in 10s
Seed script extends the fixture matrix with a shared "Content Fixtures" pond (owned by fixture-user): an "Every Element" page covering every editor schema node and mark (#24), and a "Fixture Image" page with one real, servable uploaded image. "Every Element" loads a checked-in Yjs snapshot (prisma/fixtures/content-page.yjs) generated from a human-readable Markdown source (content-page.md) via a deterministic regeneration script (pinned Y.Doc clientID; refuses to write a snapshot that isn't a fixed point of the Markdown round-trip). New apps/web/e2e/content.spec.ts consolidates the M2 content regression pack: page lifecycle, editor basics, image paste, trash, and — the pack's actual regression pin — a byte-for-byte comparison of the fixture page's exported Markdown against the checked-in fixture. Verified this catches regressions: temporarily mutated docToMarkdown's heading serializer, rebuilt, re-seeded, confirmed the comparison failed, then reverted. This pack now runs in CI (a second step in the existing auth-e2e job, reusing its already-built-and-seeded stack) alongside the existing local-only feature packs. Closes #32
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
/**
|
|
* Development/Test fixture seeding. Idempotent: running it twice must not
|
|
* duplicate anything. Never run against production data.
|
|
*
|
|
* Fixture matrix (documented in apps/web/e2e/README.md):
|
|
* fixture-admin active, Site Admin
|
|
* fixture-user active, regular account
|
|
* fixture-pending registered but e-mail not verified
|
|
*
|
|
* All fixture accounts share the password below — they exist only on
|
|
* dev machines and disposable CI/Test databases. On shared stages
|
|
* (test/int), set FIXTURE_ADMIN_PASSWORD / FIXTURE_USER_PASSWORD to give
|
|
* those two accounts non-public passwords.
|
|
*
|
|
* Content fixtures (issue #32): a shared pond owned by fixture-user with
|
|
* two pages — "Every Element" (every editor schema node/mark, #24, loaded
|
|
* from the checked-in `fixtures/content-page.yjs`; regenerate via
|
|
* `pnpm --filter @dorfteich/api fixtures:regenerate` after editing
|
|
* `fixtures/content-page.md`) and "Fixture Image" (one real, servable
|
|
* uploaded image) — for the content regression pack and manual QA.
|
|
*/
|
|
import { readFileSync } from 'node:fs';
|
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
docToHtml,
|
|
docToMarkdown,
|
|
docToPlainText,
|
|
editorSchema,
|
|
extractOutline,
|
|
slugify,
|
|
} from '@dorfteich/shared';
|
|
import { Prisma, PrismaClient, UserStatus } from '@prisma/client';
|
|
import { generateKeyBetween } from 'fractional-indexing';
|
|
import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProseMirrorRootNode } from 'y-prosemirror';
|
|
import * as Y from 'yjs';
|
|
import { Node as ProseMirrorNode } from 'prosemirror-model';
|
|
|
|
import { hashPassword } from '../src/users/password';
|
|
|
|
export const FIXTURE_PASSWORD = 'fixture passwort 123';
|
|
|
|
const PASSWORD_OVERRIDES: Record<string, string | undefined> = {
|
|
'fixture-admin': process.env.FIXTURE_ADMIN_PASSWORD,
|
|
'fixture-user': process.env.FIXTURE_USER_PASSWORD,
|
|
};
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
interface FixtureUser {
|
|
username: string;
|
|
displayName: string;
|
|
status: UserStatus;
|
|
isSiteAdmin: boolean;
|
|
}
|
|
|
|
const FIXTURES: FixtureUser[] = [
|
|
{ username: 'fixture-admin', displayName: 'Fixture Admin', status: 'ACTIVE', isSiteAdmin: true },
|
|
{ username: 'fixture-user', displayName: 'Fixture User', status: 'ACTIVE', isSiteAdmin: false },
|
|
{
|
|
username: 'fixture-pending',
|
|
displayName: 'Fixture Pending',
|
|
status: 'PENDING_VERIFICATION',
|
|
isSiteAdmin: false,
|
|
},
|
|
];
|
|
|
|
async function upsertFixtureUser(fixture: FixtureUser): Promise<string> {
|
|
const email = `${fixture.username}@dorfteich.test`;
|
|
const user = await prisma.user.upsert({
|
|
where: { username: fixture.username },
|
|
create: {
|
|
username: fixture.username,
|
|
email,
|
|
displayName: fixture.displayName,
|
|
locale: 'de',
|
|
status: fixture.status,
|
|
isSiteAdmin: fixture.isSiteAdmin,
|
|
emailVerifiedAt: fixture.status === 'ACTIVE' ? new Date() : null,
|
|
},
|
|
update: {
|
|
status: fixture.status,
|
|
isSiteAdmin: fixture.isSiteAdmin,
|
|
},
|
|
});
|
|
// Re-hash on every run so a changed override takes effect on re-seed.
|
|
const credential = await hashPassword(PASSWORD_OVERRIDES[fixture.username] ?? FIXTURE_PASSWORD);
|
|
await prisma.userIdentity.upsert({
|
|
where: { provider_subject: { provider: 'password', subject: user.id } },
|
|
create: {
|
|
userId: user.id,
|
|
provider: 'password',
|
|
subject: user.id,
|
|
credential,
|
|
},
|
|
update: { credential },
|
|
});
|
|
// Active accounts get their personal pond, mirroring what e-mail
|
|
// verification does for real signups (issue #21).
|
|
if (fixture.status === 'ACTIVE') {
|
|
const existing = await prisma.pond.findFirst({
|
|
where: { ownerId: user.id, type: 'PERSONAL' },
|
|
select: { id: true },
|
|
});
|
|
if (!existing) {
|
|
await prisma.pond.create({
|
|
data: {
|
|
slug: slugify(fixture.displayName) || fixture.username,
|
|
name: fixture.displayName,
|
|
type: 'PERSONAL',
|
|
ownerId: user.id,
|
|
},
|
|
});
|
|
}
|
|
// The instance default for additional_ponds is 0 (ADR 0011) — give
|
|
// the fixtures headroom so pond flows are exercisable in dev/e2e.
|
|
await prisma.quotaOverride.upsert({
|
|
where: {
|
|
subjectType_subjectId_quotaKey: {
|
|
subjectType: 'USER',
|
|
subjectId: user.id,
|
|
quotaKey: 'additional_ponds',
|
|
},
|
|
},
|
|
create: {
|
|
subjectType: 'USER',
|
|
subjectId: user.id,
|
|
quotaKey: 'additional_ponds',
|
|
value: 100,
|
|
},
|
|
update: { value: 100 },
|
|
});
|
|
}
|
|
return user.id;
|
|
}
|
|
|
|
interface DerivedContent {
|
|
plainText: string;
|
|
markdown: string;
|
|
html: string;
|
|
outline: Prisma.InputJsonValue;
|
|
}
|
|
|
|
function deriveContentOf(doc: ProseMirrorNode): DerivedContent {
|
|
return {
|
|
plainText: docToPlainText(doc),
|
|
markdown: docToMarkdown(doc),
|
|
html: docToHtml(doc),
|
|
outline: extractOutline(doc) as unknown as Prisma.InputJsonValue,
|
|
};
|
|
}
|
|
|
|
function docFromYjsState(state: Uint8Array): ProseMirrorNode {
|
|
const ydoc = new Y.Doc();
|
|
Y.applyUpdate(ydoc, state);
|
|
const doc = yXmlFragmentToProseMirrorRootNode(ydoc.getXmlFragment('default'), editorSchema);
|
|
ydoc.destroy();
|
|
return doc;
|
|
}
|
|
|
|
async function upsertFixturePage(
|
|
pondId: string,
|
|
slug: string,
|
|
title: string,
|
|
ownerId: string,
|
|
state: Uint8Array,
|
|
content: DerivedContent,
|
|
): Promise<string> {
|
|
const existing = await prisma.page.findFirst({ where: { pondId, slug }, select: { id: true } });
|
|
if (existing) {
|
|
await prisma.page.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
ydocState: state,
|
|
contentCache: { upsert: { create: content, update: content } },
|
|
},
|
|
});
|
|
return existing.id;
|
|
}
|
|
const last = await prisma.page.findFirst({
|
|
where: { pondId },
|
|
orderBy: { sortKey: 'desc' },
|
|
select: { sortKey: true },
|
|
});
|
|
const page = await prisma.page.create({
|
|
data: {
|
|
pondId,
|
|
title,
|
|
slug,
|
|
sortKey: generateKeyBetween(last?.sortKey ?? null, null),
|
|
ydocState: state,
|
|
createdBy: ownerId,
|
|
contentCache: { create: content },
|
|
},
|
|
});
|
|
return page.id;
|
|
}
|
|
|
|
const FIXTURE_POND_SLUG = 'content-fixtures';
|
|
// 1x1 transparent PNG — only the magic bytes matter for a real, servable
|
|
// (if visually trivial) fixture image.
|
|
const FIXTURE_IMAGE_PNG_BASE64 =
|
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
|
|
const FIXTURE_IMAGE_ID = '00000000-0000-4000-8000-000000000001';
|
|
|
|
/** Shared content pond + fixture pages (issue #32). */
|
|
async function seedContentFixtures(ownerId: string): Promise<void> {
|
|
let pond = await prisma.pond.findUnique({ where: { slug: FIXTURE_POND_SLUG } });
|
|
if (!pond) {
|
|
pond = await prisma.pond.create({
|
|
data: {
|
|
slug: FIXTURE_POND_SLUG,
|
|
name: 'Content Fixtures',
|
|
type: 'SHARED',
|
|
ownerId,
|
|
},
|
|
});
|
|
}
|
|
await prisma.quotaOverride.upsert({
|
|
where: {
|
|
subjectType_subjectId_quotaKey: {
|
|
subjectType: 'POND',
|
|
subjectId: pond.id,
|
|
quotaKey: 'storage_bytes',
|
|
},
|
|
},
|
|
create: {
|
|
subjectType: 'POND',
|
|
subjectId: pond.id,
|
|
quotaKey: 'storage_bytes',
|
|
value: 100 * 1024 * 1024,
|
|
},
|
|
update: { value: 100 * 1024 * 1024 },
|
|
});
|
|
|
|
// "Every Element": the checked-in Yjs snapshot regenerated from
|
|
// fixtures/content-page.md — covers every editor schema node/mark (#24).
|
|
const everyElementState = readFileSync(join(__dirname, 'fixtures/content-page.yjs'));
|
|
const everyElementDoc = docFromYjsState(everyElementState);
|
|
await upsertFixturePage(
|
|
pond.id,
|
|
'every-element',
|
|
'Every Element',
|
|
ownerId,
|
|
new Uint8Array(everyElementState),
|
|
deriveContentOf(everyElementDoc),
|
|
);
|
|
|
|
// "Fixture Image": one real, servable uploaded image (the Markdown
|
|
// fixture above only carries a placeholder fileId for round-trip
|
|
// testing — this is the one that actually resolves via /media/:fileId).
|
|
const uploadsDir = process.env.UPLOADS_DIR ?? './data/uploads';
|
|
const imageBytes = Buffer.from(FIXTURE_IMAGE_PNG_BASE64, 'base64');
|
|
await mkdir(join(uploadsDir, pond.id), { recursive: true });
|
|
await writeFile(join(uploadsDir, pond.id, FIXTURE_IMAGE_ID), imageBytes);
|
|
await prisma.attachment.upsert({
|
|
where: { id: FIXTURE_IMAGE_ID },
|
|
create: {
|
|
id: FIXTURE_IMAGE_ID,
|
|
pondId: pond.id,
|
|
fileName: 'fixture.png',
|
|
mimeType: 'image/png',
|
|
sizeBytes: imageBytes.length,
|
|
storagePath: `${pond.id}/${FIXTURE_IMAGE_ID}`,
|
|
uploadedBy: ownerId,
|
|
},
|
|
update: {},
|
|
});
|
|
await prisma.pondUsage.upsert({
|
|
where: { pondId: pond.id },
|
|
create: { pondId: pond.id, storageBytesUsed: imageBytes.length },
|
|
update: {},
|
|
});
|
|
|
|
const imageDoc = editorSchema.node('doc', null, [
|
|
editorSchema.node('heading', { level: 1 }, [editorSchema.text('Fixture Image')]),
|
|
editorSchema.node('paragraph', null, [
|
|
editorSchema.node('image', {
|
|
fileId: FIXTURE_IMAGE_ID,
|
|
alt: 'A tiny fixture image',
|
|
width: null,
|
|
}),
|
|
]),
|
|
]);
|
|
const imageYdoc = new Y.Doc();
|
|
prosemirrorJSONToYXmlFragment(
|
|
editorSchema,
|
|
imageDoc.toJSON(),
|
|
imageYdoc.getXmlFragment('default'),
|
|
);
|
|
const imageState = new Uint8Array(Y.encodeStateAsUpdate(imageYdoc));
|
|
imageYdoc.destroy();
|
|
|
|
const imagePageId = await upsertFixturePage(
|
|
pond.id,
|
|
'fixture-image',
|
|
'Fixture Image',
|
|
ownerId,
|
|
imageState,
|
|
deriveContentOf(imageDoc),
|
|
);
|
|
await prisma.attachment.update({
|
|
where: { id: FIXTURE_IMAGE_ID },
|
|
data: { pageId: imagePageId },
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
// Fresh rate-limit budget for e2e runs — seed targets are always
|
|
// disposable dev/CI databases, never production.
|
|
await prisma.rateLimit.deleteMany({});
|
|
let contentOwnerId: string | undefined;
|
|
for (const fixture of FIXTURES) {
|
|
const userId = await upsertFixtureUser(fixture);
|
|
if (fixture.username === 'fixture-user') contentOwnerId = userId;
|
|
}
|
|
if (contentOwnerId) await seedContentFixtures(contentOwnerId);
|
|
await prisma.instanceSetting.upsert({
|
|
where: { key: 'seed.marker' },
|
|
create: { key: 'seed.marker', value: { seededAt: new Date().toISOString() } },
|
|
update: { value: { seededAt: new Date().toISOString() } },
|
|
});
|
|
console.log(`seed: done (${FIXTURES.length} fixture users)`);
|
|
}
|
|
|
|
main()
|
|
.catch((error) => {
|
|
console.error(error);
|
|
process.exitCode = 1;
|
|
})
|
|
.finally(() => prisma.$disconnect());
|