From 6d971e5684d308e3c267d965ae311375d7fa9bd0 Mon Sep 17 00:00:00 2001 From: Michael Schwobe Date: Mon, 20 Nov 2023 13:47:06 -0600 Subject: [PATCH] - Added `@faker-js/faker` and `enforce-unique` packages - Created db utils for seeding/testing - Updated db `seed` function to honor `MINIMAL_SEED` env --- README.md | 2 +- package.json | 2 + pnpm-lock.yaml | 15 +++ prisma/data.db | Bin 94208 -> 94208 bytes prisma/seed.ts | 141 ++++++++++--------- tests/e2e/bookmarks._index.test.ts | 6 +- tests/utils/db-utils.ts | 208 +++++++++++++++++++++++++++++ 7 files changed, 306 insertions(+), 68 deletions(-) create mode 100644 tests/utils/db-utils.ts diff --git a/README.md b/README.md index 6fe338a..b12ff9b 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ TagsForDays extends traditional bookmarking with advanced organization and searc ### MVP -- TODO: Add database writes/resets (seeding, testing) +- TODO: Add database writes/resets (testing) - TODO: Add database "Collection" model (grouped bookmarks, relations, other) - Complete all TODOs found in codebase diff --git a/package.json b/package.json index dcb7f11..8186335 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@faker-js/faker": "^8.3.1", "@flydotio/dockerfile": "^0.4.10", "@playwright/test": "^1.40.0", "@remix-run/dev": "^2.3.0", @@ -81,6 +82,7 @@ "@vitejs/plugin-react": "^4.2.0", "@vitest/coverage-v8": "^0.34.6", "@vitest/ui": "^0.34.6", + "enforce-unique": "^1.2.0", "eslint": "^8.54.0", "eslint-config-prettier": "^9.0.0", "prettier": "^3.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4bcdd..d09ba0e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -79,6 +79,9 @@ dependencies: version: 3.22.4 devDependencies: + '@faker-js/faker': + specifier: ^8.3.1 + version: 8.3.1 '@flydotio/dockerfile': specifier: ^0.4.10 version: 0.4.10 @@ -139,6 +142,9 @@ devDependencies: '@vitest/ui': specifier: ^0.34.6 version: 0.34.6(vitest@0.34.6) + enforce-unique: + specifier: ^1.2.0 + version: 1.2.0 eslint: specifier: ^8.54.0 version: 8.54.0 @@ -1081,6 +1087,11 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@faker-js/faker@8.3.1: + resolution: {integrity: sha512-FdgpFxY6V6rLZE9mmIBb9hM0xpfvQOSNOLnzolzKwsE1DH+gC7lEKV1p1IbR0lAYyvYd5a4u3qWJzowUkw1bIw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0, npm: '>=6.14.13'} + dev: true + /@fastify/busboy@2.1.0: resolution: {integrity: sha512-+KpH+QxZU7O4675t3mnkQKcZZg56u+K/Ct2K+N2AZYNVK8kyeo/bI18tI8aPm3tvNNRyTWfj6s5tnGNlcbQRsA==} engines: {node: '>=14'} @@ -3529,6 +3540,10 @@ packages: once: 1.4.0 dev: true + /enforce-unique@1.2.0: + resolution: {integrity: sha512-ZBusLJB8QQhKMGNOlwARgov+s38rwPbhcifGlhEHqnGHcA8CCSEgbLujdABtpWwZyYnude4t5fviuEmSuV8Hig==} + dev: true + /enhanced-resolve@5.15.0: resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} engines: {node: '>=10.13.0'} diff --git a/prisma/data.db b/prisma/data.db index 6c38e71d010980ead330f10659bc3acf64c3be2e..1ff6ddc8f1511ee3c7aace73c3f88679deb44833 100644 GIT binary patch delta 1853 zcmchYS!fec6oz{8qwPGdV&QCuV9)?_j>SwyL*57uI>iY*$q#0fE4O`E1wLu1@< z$1M+|mQoN@a6xPyR0PH4MJqm4d~l(H5iF<(f<+X(b8jXOwPD`OL%3f!|2gO0``?p& z1IB#=#xvv5gR>JBqX)M)cht=C1m&35seU|LLcEiA1|veSr7gN6)ZP|~p%Y&h8Eo{s zci|vZjU3+F&^xJK7WiObRixV4C~b3lB7u;ju71smaHE(If@@k^1N`c^yrOB5H|AO5 zN`!0WrJ;Iv`rxUIHR#99+AEAK-h;8 z%fAoOHmeHe63ls#ghg(2@cFM@lgO?Li#C*|&m?`Rc3=A;zfP!7lFu*V{1S@1@1MC< zMhq>QnW)iR(a5|4@;gs)l)I~QZ6hK(9z)MXq}$OkQeb-gArL`D=4@KjHVzE zuu%{Rlv9uh00sO$Ws$W~P_v1wg@T$*WXmY1*+iD5AQr2X-rI9-DmbifAPe{5AS|$c zhG%dV>a0JZ2iAfS!cYNI@LTh-neEp*nUus?iaBx&r0q(LNZ%D2k;X@BL^`)=L|QM` zi1ZE`k>;%$k?t)Tk@l@+8mdXfbo^E~vm0c5M-4Exf|-rB#^Wt*^Bj&uRIZjo9j2*F zEmCZLG%<7WVZ-D?vua?lMjUzO{BLh4SqHLnhp`nOVKb^$ybDQU#W>z%BUBL+;VDN= zh$oksXTm7mm7H5mO^An03Gt;mK&T7xOrS2r@0_|2Kixao8Z@WeaG1rn+iF%0twTuz zV^UuG(WV)(q$|)vUd{A^6LkMdqr&*nP5aK5H+GHF30BsvM_28~hh8+>^&+QlK)>vX zWtCqQy~xEI(GUBP^X{H$dXfH3Xur4P-Ipf%zg2is>UEo-kB)**_}u%U51;#dP#U7> PfLS8Ps5(!18%6fNUdt4R delta 1845 zcmchXUr19?9LINd-E_O|{x;{;hk{Iupu+vR_s%Vf8B%G{S{a#|S#$FbT3c>Y32j@CMayzUX#z?B<*8b*^Qa>Zm@i^SIArX2*m zmghAZccjbjj9lq((dFAgx(p;?$dMh2Z8$EmK+BiXtz4M0S+ipsC?P+ymxw7TH%N=2 z7~QK1Q_kEdO=c?xhN^Od@-mQsA#ojwjcsx4Oop;!N?511g47wvh9UblP$NyLfHIU7 zQ?_j=3h7lU6$|4k_zS;a9KOR>_yDhA0p?*6W|O9}=UjYtbh%c?lp-7*o0TnKc)4=w z(}ko<7p{VIA?BxxDs8%OXwrp{Og%1vn8+40JTLe2b@@fsOog3@A}RzTOjL+O7^$#g zA+bP(+(2SED&z(dYoJ1IAhCs1$PFZxr9#S+Rs80}SUL9@c0&r@!)@*dSl}Z(<^I57 zm;eo&1T!>32YzfWHrYKj$izh6kjImcC$@_fMSPnTMT{3Iia0kZidZ))ig*V_5%Zj) zhi{(bpU+E9@C7;cC%Ql-+ zODk?M`@H$zu}GvWsDh8+6u!ebq$!*xDSUyy?>r>#x{; + + console.log(["\n", MESSAGES.initialize, "\n"].join("")); + console.time(MESSAGES.createData); + + console.time(MESSAGES.deleteData); + await deleteData(prisma); + console.timeEnd(MESSAGES.deleteData); + + console.time(MESSAGES.createUser); + const user = await createUser(prisma, { username: "someuser", password: "somepass", - tags: [ + }); + console.timeEnd(MESSAGES.createUser); + + console.time(MESSAGES.createTags); + const tags = await createTags(prisma, { + items: [ { id: "tid0", name: "tag1" }, { id: "tid1", name: "tag2" }, { id: "tid2", name: "tag3" }, @@ -23,14 +46,25 @@ async function seed() { { id: "tid8", name: "tag9" }, { id: "tid9", name: "tag10" }, { id: "tid10", name: "taaaaaaaaaaaaag that is exactly 45 characters" }, - ], - bookmarks: [ + ] as const satisfies ReadonlyArray<{ + id: string; + name: string; + createdAt?: Date | string | undefined; + }>, + userId: user.id, + }); + console.timeEnd(MESSAGES.createTags); + + console.time(MESSAGES.createBookmarks); + const bookmarks = await createBookmarks(prisma, { + items: [ { id: "bid0", url: "https://conform.guide", title: "Conform", content: "A progressive enhancement first form validation library for Remix and React Router.", + favorite: true, }, { id: "bid1", @@ -38,6 +72,7 @@ async function seed() { title: "Prisma", content: "Prisma is a next-generation Node.js and TypeScript ORM for PostgreSQL, MySQL, SQL Server, SQLite, MongoDB, and CockroachDB. It provides type-safety, automated migrations, and an intuitive data model.", + favorite: false, }, { id: "bid2", @@ -45,6 +80,7 @@ async function seed() { title: "Remix", content: "Remix is a full stack web framework that lets you focus on the user interface and work back through web standards to deliver a fast, slick, and resilient user experience. People are gonna love using your stuff.", + favorite: true, }, { id: "bid3", @@ -52,6 +88,7 @@ async function seed() { title: "Tailwind CSS", content: "Tailwind CSS is a utility-first CSS framework for rapidly building modern websites without ever leaving your HTML.", + favorite: false, }, { id: "bid4", @@ -59,6 +96,7 @@ async function seed() { title: "TypeScript", content: "TypeScript is a strongly typed programming language that builds on JavaScript, giving you better tooling at any scale.", + favorite: true, }, { id: "bid5", @@ -66,66 +104,41 @@ async function seed() { title: "Zod", content: "TypeScript-first schema validation with static type inference.", + favorite: false, }, - ], - } as const; - - console.log(CONSTANTS.initialize); - console.time(CONSTANTS.createData); - - console.time(CONSTANTS.deleteData); - await prisma.user - .delete({ where: { username: CONSTANTS.username } }) - .catch(() => {}); - console.timeEnd(CONSTANTS.deleteData); - - console.time(CONSTANTS.createUser); - const hashedPassword = await bcrypt.hash(CONSTANTS.password, 10); - const { id: userId } = await prisma.user.create({ - data: { - username: CONSTANTS.username, - password: { create: { hash: hashedPassword } }, - }, - select: { id: true }, + ] as const satisfies ReadonlyArray<{ + id: string; + url: string; + title?: string | null | undefined; + content?: string | null | undefined; + favorite?: boolean | null | undefined; + createdAt?: Date | string | undefined; + }>, + tags: tags, + userId: user.id, }); - console.timeEnd(CONSTANTS.createUser); + console.timeEnd(MESSAGES.createBookmarks); - console.time(CONSTANTS.createTags); - const tags = await Promise.all( - CONSTANTS.tags.map( - async ({ id, name }) => - await prisma.tag.create({ - data: { id, name, userId }, - select: { id: true }, - }), - ), - ); - console.timeEnd(CONSTANTS.createTags); + if (!process.env["MINIMAL_SEED"]) { + console.time(MESSAGES.createMoreTags); + const tagsMore = await createTags(prisma, { + length: 50, + start: tags.length, + userId: user.id, + }); + console.timeEnd(MESSAGES.createMoreTags); - console.time(CONSTANTS.createBookmarks); - await Promise.all( - CONSTANTS.bookmarks.map( - async (bookmark, bookmarkIdx) => - await prisma.bookmark.create({ - data: { - id: bookmark.id, - url: bookmark.url, - title: bookmark.title, - content: bookmark.content, - favorite: bookmarkIdx % 2 === 0, - tags: { - create: tags - .slice(0, bookmarkIdx + 1) - .map((tag) => ({ tag: { connect: { id: tag.id } } })), - }, - userId, - }, - }), - ), - ); - console.timeEnd(CONSTANTS.createBookmarks); + console.time(MESSAGES.createMoreBookmarks); + await createBookmarks(prisma, { + length: 50, + start: bookmarks.length, + tags: tagsMore, + userId: user.id, + }); + console.timeEnd(MESSAGES.createMoreBookmarks); + } - console.timeEnd(CONSTANTS.createData); + console.timeEnd(MESSAGES.createData); } seed() diff --git a/tests/e2e/bookmarks._index.test.ts b/tests/e2e/bookmarks._index.test.ts index bd0a2df..be9a533 100644 --- a/tests/e2e/bookmarks._index.test.ts +++ b/tests/e2e/bookmarks._index.test.ts @@ -165,8 +165,8 @@ test.describe("Unauthenticated", () => { test("User can NOT (un)favorite a bookmark", async ({ page }) => { await page + .getByRole("row", { name: "Conform" }) .getByRole("button", { name: "Unfavorite bookmark", exact: true }) - .first() .click(); await expect(page).toHaveURL( @@ -197,8 +197,8 @@ test.describe("Authenticated", () => { test("User can (un)favorite a bookmark", async ({ page }) => { await expect( page - .getByRole("button", { name: "Unfavorite bookmark", exact: true }) - .first(), + .getByRole("row", { name: "Conform" }) + .getByRole("button", { name: "Unfavorite bookmark", exact: true }), ).toBeVisible(); }); }); diff --git a/tests/utils/db-utils.ts b/tests/utils/db-utils.ts new file mode 100644 index 0000000..4dcd0ed --- /dev/null +++ b/tests/utils/db-utils.ts @@ -0,0 +1,208 @@ +import { faker } from "@faker-js/faker"; +import type { PrismaClient } from "@prisma/client"; +import bcrypt from "bcryptjs"; +import { UniqueEnforcer } from "enforce-unique"; + +const UniqueEnforcerBookmarkId = new UniqueEnforcer(); +const UniqueEnforcerBookmarkUrl = new UniqueEnforcer(); +const UniqueEnforcerTagId = new UniqueEnforcer(); +const UniqueEnforcerTagName = new UniqueEnforcer(); + +export function generateNumberArray({ + length, + start = 0, +}: { + length: number; + start?: number | undefined; +}) { + return [...Array(length + start).keys()].slice(start); +} + +export function generateUser({ + username, + password, +}: { + username?: string; + password?: string; +} = {}) { + return { + username: username ?? faker.internet.userName(), + hash: bcrypt.hashSync(password ?? faker.internet.password(), 10), + } as const; +} + +export function generateTag({ + id, + name, + createdAt, +}: { + id?: string | undefined; + name?: string | undefined; + createdAt?: string | undefined; +} = {}) { + return { + id: id ?? UniqueEnforcerTagId.enforce(() => faker.string.uuid()), + name: + name ?? + UniqueEnforcerTagName.enforce(() => + faker.word.adjective({ length: { min: 2, max: 45 } }), + ), + createdAt: createdAt ?? faker.date.past({ years: 5 }), + } as const; +} + +export function generateTags({ + items, + length, + start, +}: + | { + items: ReadonlyArray<{ + createdAt?: string | undefined; + id?: string | undefined; + name?: string | undefined; + }>; + length?: never; + start?: never; + } + | { + items?: never; + length: number; + start?: number | undefined; + }) { + return items + ? items.map((item) => generateTag(item)) + : generateNumberArray({ length, start }).map(() => generateTag()); +} + +export function generateBookmark({ + id, + url, + title, + content, + favorite, + createdAt, +}: { + id?: string | undefined; + url?: string | undefined; + title?: string | null | undefined; + content?: string | null | undefined; + favorite?: boolean | null | undefined; + createdAt?: string | undefined; +} = {}) { + return { + id: id ?? UniqueEnforcerBookmarkId.enforce(() => faker.string.uuid()), + url: url ?? UniqueEnforcerBookmarkUrl.enforce(() => faker.internet.url()), + title: title ?? faker.lorem.sentence({ min: 2, max: 16 }), + content: content ?? faker.lorem.paragraphs({ min: 2, max: 16 }), + favorite: favorite !== undefined ? favorite : faker.datatype.boolean(), + createdAt: createdAt ?? faker.date.past({ years: 5 }), + } as const; +} + +function generateBookmarks({ + items, + length, + start, +}: + | { + items: ReadonlyArray<{ + id?: string | undefined; + url?: string | undefined; + title?: string | null | undefined; + content?: string | null | undefined; + favorite?: boolean | null | undefined; + createdAt?: string | undefined; + }>; + length?: never; + start?: never; + } + | { + items?: never; + length: number; + start?: number | undefined; + }) { + return items + ? items.map((item) => generateBookmark(item)) + : generateNumberArray({ length, start }).map(() => generateBookmark()); +} + +export async function createUser( + prisma: PrismaClient, + values: Parameters[0], +) { + const { username, hash } = generateUser(values); + return await prisma.user.create({ + data: { + username, + password: { create: { hash } }, + }, + }); +} + +export async function createTags( + prisma: PrismaClient, + values: Parameters[0] & { userId: string }, +) { + const { userId, ...params } = values; + return await Promise.all( + generateTags(params).map( + async ({ id, name, createdAt }) => + await prisma.tag.create({ + data: { id, name, createdAt, userId }, + }), + ), + ); +} + +export async function createBookmarks( + prisma: PrismaClient, + values: Parameters[0] & { + tags?: ReadonlyArray<{ id: string }> | undefined; + } & { + userId: string; + }, +) { + const { tags, userId, ...params } = values; + return await Promise.all( + generateBookmarks(params).map( + async ({ id, url, title, content, favorite, createdAt }, idx) => + await prisma.bookmark.create({ + data: { + id, + url, + title, + content, + favorite, + createdAt, + ...(Array.isArray(tags) && tags.length > 0 + ? { + tags: { + create: tags + .slice(0, idx + 1) + .map((tag) => ({ tag: { connect: { id: tag.id } } })), + }, + } + : {}), + userId, + }, + }), + ), + ); +} + +export async function deleteData(prisma: PrismaClient) { + const tables = await prisma.$queryRaw< + { name: string }[] + >`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_prisma_migrations';`; + + await prisma.$transaction([ + // Disable FK constraints to avoid relation conflicts during deletion + prisma.$executeRawUnsafe(`PRAGMA foreign_keys = OFF`), + // Delete all rows from each table, preserving table structures + ...tables.map(({ name }) => + prisma.$executeRawUnsafe(`DELETE from "${name}"`), + ), + prisma.$executeRawUnsafe(`PRAGMA foreign_keys = ON`), + ]); +}