diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13d1d7d8..ed133bf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,7 +128,7 @@ importers: version: 9.6.1(react@19.2.4) next: specifier: 16.1.1 - version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-static-image: specifier: ^0.0.1 version: link:../next-static-image @@ -288,7 +288,7 @@ importers: version: 9.6.1(react@19.2.4) next: specifier: 16.1.1 - version: 16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) sharp: specifier: 0.34.5 version: 0.34.5 @@ -1130,6 +1130,12 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@testing-library/cypress': + specifier: ^10.1.0 + version: 10.1.0(cypress@15.9.0) + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 '@types/node': specifier: ^25.2.0 version: 25.2.0 @@ -1139,6 +1145,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.10) + cypress: + specifier: ^15.9.0 + version: 15.9.0 eslint: specifier: ^9.39.2 version: 9.39.2(jiti@2.6.1) @@ -1148,6 +1157,12 @@ importers: postcss: specifier: ^8.5.6 version: 8.5.6 + simple-git: + specifier: ^3.30.0 + version: 3.30.0 + start-server-and-test: + specifier: ^2.1.3 + version: 2.1.3 tailwindcss: specifier: ^4.1.18 version: 4.1.18 @@ -1193,42 +1208,22 @@ packages: nodemailer: optional: true - '@babel/code-frame@7.27.1': - resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} - engines: {node: '>=6.9.0'} - '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.28.5': - resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} - engines: {node: '>=6.9.0'} - '@babel/compat-data@7.29.0': resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} engines: {node: '>=6.9.0'} - '@babel/core@7.28.5': - resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} - engines: {node: '>=6.9.0'} - '@babel/core@7.29.0': resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.28.5': - resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} - engines: {node: '>=6.9.0'} - '@babel/generator@7.29.0': resolution: {integrity: sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.27.2': - resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.28.6': resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} engines: {node: '>=6.9.0'} @@ -1237,20 +1232,10 @@ packages: resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.27.1': - resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.28.6': resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.28.3': - resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.28.6': resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} engines: {node: '>=6.9.0'} @@ -1273,19 +1258,10 @@ packages: resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.4': - resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.28.6': resolution: {integrity: sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.28.5': - resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} - engines: {node: '>=6.0.0'} - hasBin: true - '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} @@ -1398,26 +1374,14 @@ packages: resolution: {integrity: sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==} engines: {node: '>=6.9.0'} - '@babel/template@7.27.2': - resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} - engines: {node: '>=6.9.0'} - '@babel/template@7.28.6': resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.28.5': - resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.0': resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -7875,11 +7839,6 @@ packages: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true - react-dom@19.2.3: - resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} - peerDependencies: - react: ^19.2.3 - react-dom@19.2.4: resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==} peerDependencies: @@ -7941,10 +7900,6 @@ packages: '@types/react': optional: true - react@19.2.3: - resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} - engines: {node: '>=0.10.0'} - react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} @@ -9515,45 +9470,14 @@ snapshots: preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) - '@babel/code-frame@7.27.1': - dependencies: - '@babel/helper-validator-identifier': 7.28.5 - js-tokens: 4.0.0 - picocolors: 1.1.1 - optional: true - '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/compat-data@7.28.5': - optional: true - '@babel/compat-data@7.29.0': {} - '@babel/core@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-compilation-targets': 7.27.2 - '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) - '@babel/helpers': 7.28.4 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/remapping': 2.3.5 - convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@10.2.2) - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - optional: true - '@babel/core@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -9574,15 +9498,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.28.5': - dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - jsesc: 3.1.0 - optional: true - '@babel/generator@7.29.0': dependencies: '@babel/parser': 7.29.0 @@ -9591,15 +9506,6 @@ snapshots: '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 - '@babel/helper-compilation-targets@7.27.2': - dependencies: - '@babel/compat-data': 7.28.5 - '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 - lru-cache: 5.1.1 - semver: 6.3.1 - optional: true - '@babel/helper-compilation-targets@7.28.6': dependencies: '@babel/compat-data': 7.29.0 @@ -9610,14 +9516,6 @@ snapshots: '@babel/helper-globals@7.28.0': {} - '@babel/helper-module-imports@7.27.1': - dependencies: - '@babel/traverse': 7.28.5 - '@babel/types': 7.28.5 - transitivePeerDependencies: - - supports-color - optional: true - '@babel/helper-module-imports@7.28.6': dependencies: '@babel/traverse': 7.29.0 @@ -9625,16 +9523,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': - dependencies: - '@babel/core': 7.28.5 - '@babel/helper-module-imports': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5 - transitivePeerDependencies: - - supports-color - optional: true - '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -9652,22 +9540,11 @@ snapshots: '@babel/helper-validator-option@7.27.1': {} - '@babel/helpers@7.28.4': - dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - optional: true - '@babel/helpers@7.28.6': dependencies: '@babel/template': 7.28.6 '@babel/types': 7.29.0 - '@babel/parser@7.28.5': - dependencies: - '@babel/types': 7.28.5 - optional: true - '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 @@ -9769,32 +9646,12 @@ snapshots: '@babel/runtime@7.28.6': {} - '@babel/template@7.27.2': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 - optional: true - '@babel/template@7.28.6': dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.29.0 '@babel/types': 7.29.0 - '@babel/traverse@7.28.5': - dependencies: - '@babel/code-frame': 7.27.1 - '@babel/generator': 7.28.5 - '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 - debug: 4.4.3(supports-color@10.2.2) - transitivePeerDependencies: - - supports-color - optional: true - '@babel/traverse@7.29.0': dependencies: '@babel/code-frame': 7.29.0 @@ -9807,12 +9664,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - optional: true - '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -16652,16 +16503,16 @@ snapshots: next: 16.1.6(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 - next@16.1.1(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + next@16.1.1(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 16.1.1 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.9.11 caniuse-lite: 1.0.30001762 postcss: 8.4.31 - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - styled-jsx: 5.1.6(@babel/core@7.28.5)(react@19.2.3) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.4) optionalDependencies: '@next/swc-darwin-arm64': 16.1.1 '@next/swc-darwin-x64': 16.1.1 @@ -17345,11 +17196,6 @@ snapshots: minimist: 1.2.8 strip-json-comments: 2.0.1 - react-dom@19.2.3(react@19.2.3): - dependencies: - react: 19.2.3 - scheduler: 0.27.0 - react-dom@19.2.4(react@19.2.4): dependencies: react: 19.2.4 @@ -17429,8 +17275,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.10 - react@19.2.3: {} - react@19.2.4: {} read-package-up@11.0.0: @@ -18186,13 +18030,6 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - styled-jsx@5.1.6(@babel/core@7.28.5)(react@19.2.3): - dependencies: - client-only: 0.0.1 - react: 19.2.3 - optionalDependencies: - '@babel/core': 7.28.5 - styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.4): dependencies: client-only: 0.0.1 diff --git a/websites/resume-builder/cypress.config.ts b/websites/resume-builder/cypress.config.ts new file mode 100644 index 00000000..dc6af5c0 --- /dev/null +++ b/websites/resume-builder/cypress.config.ts @@ -0,0 +1,62 @@ +import { defineConfig } from "cypress"; +import { remove, copy, ensureDir } from "fs-extra"; +import { resolve } from "node:path"; +import simpleGit from "simple-git"; + +async function resetData(fixture?: string) { + const testContentDir = resolve("test-content"); + await remove(testContentDir); + if (fixture) { + await copy( + resolve("cypress", "fixtures", "test-content", fixture), + testContentDir, + ); + } else { + await ensureDir(testContentDir); + } + return null; +} + +async function copyFixtures(fixtureName: string) { + const testContentDir = resolve("test-content"); + const fixtureDir = resolve( + "cypress", + "fixtures", + "test-content", + fixtureName, + ); + await remove(fixtureDir); + await copy(testContentDir, fixtureDir); + return null; +} + +export default defineConfig({ + e2e: { + baseUrl: "http://localhost:3000", + defaultCommandTimeout: 10000, + setupNodeEvents(on) { + on("task", { + async getContentGitLog() { + const git = simpleGit(resolve("test-content")); + const log = await git.log(); + return log.all.map((item) => item.message); + }, + async initializeContentGit() { + const testContentDir = resolve("test-content"); + await ensureDir(testContentDir); + const git = simpleGit(testContentDir); + await git.init(); + await git + .add(".") + .commit("Initial commit", { "--allow-empty": null }); + return null; + }, + resetData, + copyFixtures, + }); + }, + retries: { + runMode: 2, + }, + }, +}); diff --git a/websites/resume-builder/cypress/e2e/create.cy.ts b/websites/resume-builder/cypress/e2e/create.cy.ts new file mode 100644 index 00000000..273f5972 --- /dev/null +++ b/websites/resume-builder/cypress/e2e/create.cy.ts @@ -0,0 +1,161 @@ +describe("Resume Create Operations", function () { + describe("Create Operations", function () { + beforeEach(function () { + cy.resetData(); + cy.visit("/"); + }); + + it("should display empty state when no resumes exist", function () { + cy.findByText("There are no resumes yet."); + }); + + it("should create a new resume with required fields only", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Company").type("Acme Corp"); + cy.findByLabelText("Job").type("Software Engineer"); + + cy.findByRole("button", { name: "Submit" }).click(); + + // Should redirect to view page + cy.url().should("include", "/resume/acme-corp-software-engineer"); + cy.findByText("Software Engineer"); + cy.findByText("Acme Corp"); + }); + + it("should auto-generate slug from company and job", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Company").type("Big Tech"); + cy.findByLabelText("Job").type("Staff Engineer"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.url().should("include", "/resume/big-tech-staff-engineer"); + }); + + it("should use custom slug when provided", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Company").type("Startup Inc"); + cy.findByLabelText("Job").type("CTO"); + cy.findByLabelText("Slug").type("my-custom-resume-slug"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.url().should("include", "/resume/my-custom-resume-slug"); + }); + + it("should create a resume with all applicant fields", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Name").type("Jane Doe"); + cy.findByLabelText("Email").type("jane@example.com"); + cy.findByLabelText("Phone").type("555-1234"); + cy.findByLabelText("Address").type("123 Main St"); + cy.findByLabelText("Github").type("janedoe"); + cy.findByLabelText("LinkedIn").type("janedoe"); + cy.findByLabelText("Website").type("janedoe.dev"); + cy.findByLabelText("Company").type("Widgets Co"); + cy.findByLabelText("Job").type("Designer"); + + cy.findByRole("button", { name: "Submit" }).click(); + + // Should redirect to view page with contact info + cy.url().should("include", "/resume/widgets-co-designer"); + cy.findByText("Jane Doe"); + cy.findByText("jane@example.com"); + cy.findByText("555-1234"); + cy.findByText("123 Main St"); + cy.findByText(/github.com\/janedoe/); + cy.findByText(/linkedin.com\/in\/janedoe/); + cy.findByText("janedoe.dev"); + }); + + it("should show the new resume in the list on homepage", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Company").type("Listed Corp"); + cy.findByLabelText("Job").type("Engineer"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.visit("/"); + cy.findByText("Listed Corp"); + }); + + it("should show validation error when required fields missing", function () { + cy.visit("/new-resume"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Failed to create Resume."); + }); + + it("should show validation error when only company is provided", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Company").type("Only Company"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Failed to create Resume."); + }); + + it("should show validation error when only job is provided", function () { + cy.visit("/new-resume"); + + cy.findByLabelText("Job").type("Only Job"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Failed to create Resume."); + }); + }); + + describe("Rapid Operations", function () { + beforeEach(function () { + cy.resetData(); + }); + + it("should handle creating multiple resumes in sequence", function () { + for (let i = 1; i <= 3; i++) { + cy.visit("/new-resume"); + cy.findByLabelText("Company").type(`Company ${i}`); + cy.findByLabelText("Job").type("Engineer"); + cy.findByRole("button", { name: "Submit" }).click(); + cy.url().should("include", `/resume/company-${i}-engineer`); + } + + cy.visit("/"); + cy.findByText("Company 3"); + cy.findByText("Company 2"); + cy.findByText("Company 1"); + }); + + it("should handle create then immediate edit", function () { + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Quick Create Co"); + cy.findByLabelText("Job").type("Tester"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByRole("link", { name: "Edit" }).click(); + cy.findByLabelText("Company").clear(); + cy.findByLabelText("Company").type("Quick Edit Co"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Quick Edit Co"); + }); + + it("should handle create then immediate delete", function () { + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Quick Delete Co"); + cy.findByLabelText("Job").type("Manager"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByRole("button", { name: "Delete" }).click(); + + cy.url().should("eq", Cypress.config().baseUrl + "/"); + cy.findByText("There are no resumes yet."); + }); + }); +}); diff --git a/websites/resume-builder/cypress/e2e/delete.cy.ts b/websites/resume-builder/cypress/e2e/delete.cy.ts new file mode 100644 index 00000000..8f0bb232 --- /dev/null +++ b/websites/resume-builder/cypress/e2e/delete.cy.ts @@ -0,0 +1,44 @@ +describe("Resume Delete Operations", function () { + describe("Delete Operations", function () { + beforeEach(function () { + cy.resetData("one-resume"); + }); + + it("should delete resume and redirect to homepage", function () { + cy.visit("/resume/acme-corp-engineer"); + cy.findByRole("button", { name: "Delete" }).click(); + + cy.url().should("eq", Cypress.config().baseUrl + "/"); + }); + + it("should show empty state after deleting the only resume", function () { + cy.visit("/resume/acme-corp-engineer"); + cy.findByRole("button", { name: "Delete" }).click(); + + cy.findByText("There are no resumes yet."); + }); + + it("should not show deleted resume on homepage", function () { + cy.visit("/resume/acme-corp-engineer"); + cy.findByRole("button", { name: "Delete" }).click(); + + cy.findByText("Acme Corp").should("not.exist"); + }); + + it("should return 404 after deleting resume", function () { + cy.visit("/resume/acme-corp-engineer"); + cy.findByRole("button", { name: "Delete" }).click(); + + // Confirm redirect completed + cy.url().should("eq", Cypress.config().baseUrl + "/"); + + // Deleted slug should return 404 + cy.request({ + url: "/resume/acme-corp-engineer", + failOnStatusCode: false, + }) + .its("status") + .should("equal", 404); + }); + }); +}); diff --git a/websites/resume-builder/cypress/e2e/git.cy.ts b/websites/resume-builder/cypress/e2e/git.cy.ts new file mode 100644 index 00000000..7aad0d63 --- /dev/null +++ b/websites/resume-builder/cypress/e2e/git.cy.ts @@ -0,0 +1,83 @@ +describe("Git Integration", function () { + beforeEach(function () { + cy.resetData(); + cy.initializeContentGit(); + }); + + it("should create git commit when creating a resume", function () { + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Git Corp"); + cy.findByLabelText("Job").type("Developer"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.url().should("include", "/resume/git-corp-developer"); + + cy.getContentGitLog().then((log) => { + expect(log).to.include("Add new resume: git-corp-developer"); + }); + }); + + it("should create git commit when updating a resume", function () { + // First create a resume + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Update Corp"); + cy.findByLabelText("Job").type("Engineer"); + cy.findByRole("button", { name: "Submit" }).click(); + + // Then update it + cy.findByRole("link", { name: "Edit" }).click(); + cy.findByLabelText("Company").clear(); + cy.findByLabelText("Company").type("Updated Corp"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Updated Corp"); + + cy.getContentGitLog().then((log) => { + expect(log).to.include("Update resume: updated-corp-engineer"); + }); + }); + + it("should create git commit when deleting a resume", function () { + // First create a resume + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Delete Corp"); + cy.findByLabelText("Job").type("Manager"); + cy.findByRole("button", { name: "Submit" }).click(); + + // Then delete it + cy.findByRole("button", { name: "Delete" }).click(); + + // Should redirect to homepage + cy.url().should("eq", Cypress.config().baseUrl + "/"); + + cy.getContentGitLog().then((log) => { + expect(log).to.include("Delete resume: delete-corp-manager"); + }); + }); + + it("should accumulate commits for multiple operations", function () { + // Create first resume + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("First Corp"); + cy.findByLabelText("Job").type("Developer"); + cy.findByRole("button", { name: "Submit" }).click(); + + // Create second resume + cy.visit("/new-resume"); + cy.findByLabelText("Company").type("Second Corp"); + cy.findByLabelText("Job").type("Designer"); + cy.findByRole("button", { name: "Submit" }).click(); + + // Update first resume + cy.visit("/resume/first-corp-developer/edit"); + cy.findByLabelText("Job").clear(); + cy.findByLabelText("Job").type("Senior Developer"); + cy.findByRole("button", { name: "Submit" }).click(); + + cy.getContentGitLog().then((log) => { + expect(log).to.include("Add new resume: first-corp-developer"); + expect(log).to.include("Add new resume: second-corp-designer"); + expect(log).to.include("Update resume: first-corp-senior-developer"); + }); + }); +}); diff --git a/websites/resume-builder/cypress/e2e/update.cy.ts b/websites/resume-builder/cypress/e2e/update.cy.ts new file mode 100644 index 00000000..bb13f4f3 --- /dev/null +++ b/websites/resume-builder/cypress/e2e/update.cy.ts @@ -0,0 +1,97 @@ +describe("Resume Update Operations", function () { + describe("Update Operations", function () { + beforeEach(function () { + cy.resetData("one-resume"); + cy.visit("/resume/acme-corp-engineer"); + }); + + it("should display edit form pre-populated with company and job", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Company").should("have.value", "Acme Corp"); + cy.findByLabelText("Job").should("have.value", "Software Engineer"); + }); + + it("should display edit form with slug pre-populated", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Slug").should("have.value", "acme-corp-engineer"); + }); + + it("should update company field", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Company").clear(); + cy.findByLabelText("Company").type("New Company"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("New Company"); + }); + + it("should update job field", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Job").clear(); + cy.findByLabelText("Job").type("Senior Engineer"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Senior Engineer"); + }); + + it("should update applicant fields", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Name").clear(); + cy.findByLabelText("Name").type("Updated Name"); + cy.findByLabelText("Email").clear(); + cy.findByLabelText("Email").type("updated@example.com"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.findByText("Updated Name"); + cy.findByText("updated@example.com"); + }); + + it("should update slug and redirect to new URL", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Slug").clear(); + cy.findByLabelText("Slug").type("new-slug-for-resume"); + + cy.findByRole("button", { name: "Submit" }).click(); + + cy.url().should("include", "/resume/new-slug-for-resume"); + + // Old URL should return 404 + cy.request({ + url: "/resume/acme-corp-engineer", + failOnStatusCode: false, + }) + .its("status") + .should("equal", 404); + }); + + it("should cancel edit and return to view page without saving", function () { + cy.findByRole("link", { name: "Edit" }).click(); + + cy.findByLabelText("Company").clear(); + cy.findByLabelText("Company").type("Should Not Be Saved"); + + cy.findByRole("link", { name: "Cancel" }).click(); + + cy.url().should("include", "/resume/acme-corp-engineer"); + cy.findByText("Should Not Be Saved").should("not.exist"); + }); + + it("should show 404 for editing non-existent resume", function () { + cy.request({ + url: "/resume/non-existent-resume/edit", + failOnStatusCode: false, + }) + .its("status") + .should("equal", 404); + }); + }); +}); diff --git a/websites/resume-builder/cypress/e2e/view.cy.ts b/websites/resume-builder/cypress/e2e/view.cy.ts new file mode 100644 index 00000000..ec7326a0 --- /dev/null +++ b/websites/resume-builder/cypress/e2e/view.cy.ts @@ -0,0 +1,57 @@ +describe("Resume View Operations", function () { + describe("View Operations", function () { + beforeEach(function () { + cy.resetData("one-resume"); + cy.visit("/resume/acme-corp-engineer"); + }); + + it("should display job title and company", function () { + cy.findByText("Software Engineer"); + cy.findByText("Acme Corp"); + }); + + it("should display contact info fields", function () { + cy.findByText("Jane Doe"); + cy.findByText("jane@example.com"); + cy.findByText("555-1234"); + cy.findByText("123 Main St"); + cy.findByText(/github.com\/janedoe/); + cy.findByText(/linkedin.com\/in\/janedoe/); + cy.findByText("janedoe.dev"); + }); + + it("should show Edit and Copy links", function () { + cy.findByRole("link", { name: "Edit" }); + cy.findByRole("link", { name: "Copy" }); + }); + + it("should show Delete button", function () { + cy.findByRole("button", { name: "Delete" }); + }); + + it("should navigate to edit page", function () { + cy.findByRole("link", { name: "Edit" }).click(); + cy.url().should("include", "/resume/acme-corp-engineer/edit"); + }); + + it("should navigate to copy page", function () { + cy.findByRole("link", { name: "Copy" }).click(); + cy.url().should("include", "/resume/acme-corp-engineer/copy"); + }); + }); + + describe("404 Handling", function () { + beforeEach(function () { + cy.resetData("one-resume"); + }); + + it("should return 404 for non-existent slug", function () { + cy.request({ + url: "/resume/non-existent-slug", + failOnStatusCode: false, + }) + .its("status") + .should("equal", 404); + }); + }); +}); diff --git a/websites/resume-builder/cypress/fixtures/generate-one-resume.cy.ts b/websites/resume-builder/cypress/fixtures/generate-one-resume.cy.ts new file mode 100644 index 00000000..d1b90e08 --- /dev/null +++ b/websites/resume-builder/cypress/fixtures/generate-one-resume.cy.ts @@ -0,0 +1,44 @@ +/** + * Fixture Generation Spec + * + * This spec generates the test fixtures used by other tests. + * It should be run explicitly using the `generate-fixtures` script + * and is NOT included in the normal test suite. + * + * Run with: npm run generate-fixtures + */ + +describe("Fixture Generation", function () { + describe("one-resume fixture", function () { + it("generates one-resume fixture", function () { + cy.resetData(); + cy.visit("/"); + + // Create a resume with all fields populated + cy.visit("/new-resume"); + cy.findByLabelText("Name").type("Jane Doe"); + cy.findByLabelText("Email").type("jane@example.com"); + cy.findByLabelText("Phone").type("555-1234"); + cy.findByLabelText("Address").type("123 Main St"); + cy.findByLabelText("Github").type("janedoe"); + cy.findByLabelText("LinkedIn").type("janedoe"); + cy.findByLabelText("Website").type("janedoe.dev"); + cy.findByLabelText("Company").type("Acme Corp"); + cy.findByLabelText("Job").type("Software Engineer"); + cy.findByLabelText("Slug").type("acme-corp-engineer"); + cy.findByLabelText("Date (UTC)").type("2023-11-14T00:00"); + + cy.findByRole("button", { name: "Submit" }).click(); + + // Verify the resume was created + cy.url().should("include", "/resume/acme-corp-engineer"); + cy.findByText("Software Engineer"); + + cy.visit("/"); + cy.findByText("Acme Corp"); + + // Copy to fixtures + cy.copyFixtures("one-resume"); + }); + }); +}); diff --git a/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/data/acme-corp-engineer/resume.json b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/data/acme-corp-engineer/resume.json new file mode 100644 index 00000000..d208e027 --- /dev/null +++ b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/data/acme-corp-engineer/resume.json @@ -0,0 +1,12 @@ +{ + "company": "Acme Corp", + "job": "Software Engineer", + "name": "Jane Doe", + "phone": "555-1234", + "email": "jane@example.com", + "address": "123 Main St", + "github": "janedoe", + "linkedin": "janedoe", + "website": "janedoe.dev", + "date": 1699920000000 +} diff --git a/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/data.mdb b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/data.mdb new file mode 100644 index 00000000..d2df6db8 Binary files /dev/null and b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/data.mdb differ diff --git a/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/lock.mdb b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/lock.mdb new file mode 100644 index 00000000..f84252c0 Binary files /dev/null and b/websites/resume-builder/cypress/fixtures/test-content/one-resume/resumes/index/lock.mdb differ diff --git a/websites/resume-builder/cypress/support/commands.ts b/websites/resume-builder/cypress/support/commands.ts new file mode 100644 index 00000000..693398e4 --- /dev/null +++ b/websites/resume-builder/cypress/support/commands.ts @@ -0,0 +1,38 @@ +/// +import "@testing-library/cypress/add-commands"; + +declare global { + namespace Cypress { + interface Chainable { + resetData(fixture?: string): Chainable; + initializeContentGit(): Chainable; + getContentGitLog(): Chainable; + copyFixtures(fixtureName: string): Chainable; + checkResumesInOrder(names: string[]): Chainable; + } + } +} + +Cypress.Commands.add("resetData", (fixture) => { + cy.task("resetData", fixture); + cy.request("/settings/invalidate-cache"); +}); + +Cypress.Commands.add("initializeContentGit", () => { + cy.task("initializeContentGit"); +}); + +Cypress.Commands.add("getContentGitLog", () => { + return cy.task("getContentGitLog"); +}); + +Cypress.Commands.add("copyFixtures", (fixtureName: string) => { + cy.task("copyFixtures", fixtureName); +}); + +Cypress.Commands.add("checkResumesInOrder", (names: string[]) => { + cy.findAllByRole("listitem").should("have.length", names.length); + cy.findAllByRole("listitem").each((el, i) => + cy.wrap(el).findByText(names[i]), + ); +}); diff --git a/websites/resume-builder/cypress/support/e2e.ts b/websites/resume-builder/cypress/support/e2e.ts new file mode 100644 index 00000000..f887c29a --- /dev/null +++ b/websites/resume-builder/cypress/support/e2e.ts @@ -0,0 +1 @@ +import "./commands"; diff --git a/websites/resume-builder/cypress/tsconfig.json b/websites/resume-builder/cypress/tsconfig.json new file mode 100644 index 00000000..a35f5b83 --- /dev/null +++ b/websites/resume-builder/cypress/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "lib": ["ES2020", "dom"], + "types": ["cypress", "@testing-library/cypress"], + "module": "ESNext", + "moduleResolution": "node" + }, + "include": ["**/*.ts"] +} diff --git a/websites/resume-builder/package.json b/websites/resume-builder/package.json index 1e2a63d1..e0b8e67d 100644 --- a/websites/resume-builder/package.json +++ b/websites/resume-builder/package.json @@ -3,10 +3,17 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", "build": "next build --webpack", "start": "next start", - "lint": "next lint" + "start:test": "TEST_MODE=true next start", + "dev": "next dev", + "dev:test": "TEST_MODE=true next dev", + "lint": "next lint", + "e2e-dev": "start-server-and-test dev:test http://localhost:3000 \"cypress open --e2e\"", + "e2e-dev:headless": "start-server-and-test dev:test http://localhost:3000 \"cypress run --e2e\"", + "e2e-start": "start-server-and-test start:test http://localhost:3000 \"cypress open --e2e\"", + "e2e-start:headless": "start-server-and-test start:test http://localhost:3000 \"cypress run --e2e\"", + "generate-fixtures": "start-server-and-test dev:test http://localhost:3000 \"cypress run --config specPattern='cypress/fixtures/**/*.cy.{js,jsx,ts,tsx}'\"" }, "dependencies": { "@tailwindcss/postcss": "^4.1.18", @@ -21,12 +28,17 @@ "zod": "^4.3.6" }, "devDependencies": { + "@testing-library/cypress": "^10.1.0", + "@types/fs-extra": "^11.0.4", "@types/node": "^25.2.0", "@types/react": "^19.2.10", "@types/react-dom": "^19.2.3", + "cypress": "^15.9.0", "eslint": "^9.39.2", "eslint-config-next": "16.1.6", "postcss": "^8.5.6", + "simple-git": "^3.30.0", + "start-server-and-test": "^2.1.3", "tailwindcss": "^4.1.18", "typescript": "^5.9.3" } diff --git a/websites/resume-builder/src/app/(resumes)/new-resume/form.tsx b/websites/resume-builder/src/app/(resumes)/new-resume/form.tsx index f5a4c4f5..7a193e5e 100644 --- a/websites/resume-builder/src/app/(resumes)/new-resume/form.tsx +++ b/websites/resume-builder/src/app/(resumes)/new-resume/form.tsx @@ -4,7 +4,7 @@ import { useActionState } from "react"; import { SubmitButton } from "component-library/components/SubmitButton"; import { Resume } from "@/controller/types"; import { ResumeFormState } from "@/controller/formState"; -import createResume from "@/controller/actions/createResume"; +import createResume from "@/controller/actions/create"; import CreateResumeFields from "@/components/Resume/Form/Create"; export default function NewResumeForm({ diff --git a/websites/resume-builder/src/app/(resumes)/resume/[slug]/copy/form.tsx b/websites/resume-builder/src/app/(resumes)/resume/[slug]/copy/form.tsx index 40a733b7..b2b3d68e 100644 --- a/websites/resume-builder/src/app/(resumes)/resume/[slug]/copy/form.tsx +++ b/websites/resume-builder/src/app/(resumes)/resume/[slug]/copy/form.tsx @@ -3,7 +3,7 @@ import { useActionState } from "react"; import { Button } from "@/components/Button"; import { ResumeFormState } from "@/controller/formState"; -import createResume from "@/controller/actions/createResume"; +import createResume from "@/controller/actions/create"; import { Resume } from "@/controller/types"; import UpdateResumeFields from "@/components/Resume/Form/Update"; diff --git a/websites/resume-builder/src/components/Resume/View/index.tsx b/websites/resume-builder/src/components/Resume/View/index.tsx index 46d1929e..a3177780 100644 --- a/websites/resume-builder/src/components/Resume/View/index.tsx +++ b/websites/resume-builder/src/components/Resume/View/index.tsx @@ -64,7 +64,7 @@ const ContactInfo = ({ )} {website && ( - + {website} @@ -117,16 +117,12 @@ const EducationList = ({ education }: { education?: Education[] }) => { Education
    {education.map(({ achievement, school, startDate, endDate }, i) => ( -
  • +
  • {achievement &&
    {achievement}
    } - {school && ( + {school &&
    {school}
    } + {startDate && (
    - {school} - {startDate && ( -
    - -
    - )} +
    )}
  • diff --git a/websites/resume-builder/src/controller/actions/create.ts b/websites/resume-builder/src/controller/actions/create.ts index 674c7a32..6d90e740 100644 --- a/websites/resume-builder/src/controller/actions/create.ts +++ b/websites/resume-builder/src/controller/actions/create.ts @@ -2,19 +2,12 @@ import parseResumeFormData from "../parseFormData"; import slugify from "@sindresorhus/slugify"; -import { writeFile } from "fs-extra"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { ResumeFormState } from "../formState"; -import { Resume } from "../types"; -import { - getResumeDirectory, - getResumeFilePath, -} from "../filesystemDirectories"; -import getResumeDatabase from "../database"; -import buildResumeIndexValue from "../buildIndexValue"; -import createDefaultSlug from "../createSlug"; -import { ensureDir } from "fs-extra"; +import type { Resume } from "../types"; +import { resumeContentConfig } from "../resumeContentConfig"; +import { createContent } from "content-engine/content/createContent"; import z from "zod"; export default async function createResume( @@ -30,53 +23,22 @@ export default async function createResume( }; } - const { - date: givenDate, - slug: givenSlug, - company, - job, - address, - email, - github, - linkedin, - name, - phone, - skills, - website, - education, - experience, - projects, - } = validatedFields.data; + const { date: givenDate, slug: givenSlug, ...rest } = validatedFields.data; + const date: number = givenDate || Date.now(); + const slug = slugify( + givenSlug || + resumeContentConfig.createDefaultSlug!({ ...rest, date }) + ); + + const data: Resume = { ...rest, date }; + + await createContent({ + config: resumeContentConfig, + slug, + data, + commitMessage: `Add new resume: ${slug}`, + }); - const date: number = givenDate || (Date.now() as number); - const slug = slugify(givenSlug || createDefaultSlug(validatedFields.data)); - const data: Resume = { - company, - job, - date, - address, - email, - github, - linkedin, - name, - phone, - skills, - website, - education, - experience, - projects, - }; - const resumeBaseDirectory = getResumeDirectory(slug); - await ensureDir(resumeBaseDirectory); - await writeFile(getResumeFilePath(resumeBaseDirectory), JSON.stringify(data)); - const db = getResumeDatabase(); - try { - await db.put([date, slug], buildResumeIndexValue(data)); - } catch { - return { message: "Failed to write resume" }; - } finally { - db.close(); - } revalidatePath("/resume/" + slug); revalidatePath("/resumes"); revalidatePath("/resumes/[page]", "page"); diff --git a/websites/resume-builder/src/controller/actions/delete.ts b/websites/resume-builder/src/controller/actions/delete.ts index 70ab3291..fd6d805b 100644 --- a/websites/resume-builder/src/controller/actions/delete.ts +++ b/websites/resume-builder/src/controller/actions/delete.ts @@ -1,29 +1,19 @@ "use server"; -import { rm } from "fs-extra"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; -import getResumeDatabase from "../database"; -import { getResumeDirectory } from "../filesystemDirectories"; -import { commitContentChanges } from "content-engine/git/commit"; - -async function removeFromDatabase(date: number, slug: string) { - const db = getResumeDatabase(); - try { - await db.remove([date, slug]); - } catch { - throw new Error("Failed to remove resume from index"); - } finally { - db.close(); - } -} +import { resumeContentConfig } from "../resumeContentConfig"; +import { deleteContent } from "content-engine/content/deleteContent"; +import type { ResumeEntryKey } from "../types"; export default async function deleteResume(date: number, slug: string) { - const resumeDirectory = getResumeDirectory(slug); - await rm(resumeDirectory, { recursive: true }); - - await removeFromDatabase(date, slug); - await commitContentChanges(`Delete resume: ${slug}`); + const indexKey: ResumeEntryKey = [date, slug]; + await deleteContent({ + config: resumeContentConfig, + slug, + indexKey, + commitMessage: `Delete resume: ${slug}`, + }); revalidatePath("/resume/" + slug); revalidatePath("/"); diff --git a/websites/resume-builder/src/controller/actions/rebuildIndex.ts b/websites/resume-builder/src/controller/actions/rebuildIndex.ts index 9d03171f..ae7e6d69 100644 --- a/websites/resume-builder/src/controller/actions/rebuildIndex.ts +++ b/websites/resume-builder/src/controller/actions/rebuildIndex.ts @@ -1,26 +1,10 @@ "use server"; -import { readJson, readdir } from "fs-extra"; import { revalidatePath } from "next/cache"; -import getResumeDatabase from "../database"; -import { - getResumeDirectory, - getResumeFilePath, - resumeDataDirectory, -} from "../filesystemDirectories"; -import { Resume } from "../types"; -import buildResumeIndexValue from "../buildIndexValue"; +import { resumeContentConfig } from "../resumeContentConfig"; +import { rebuildIndex } from "content-engine/content/rebuildIndex"; export default async function rebuildResumeIndex() { - const db = getResumeDatabase(); - await db.drop(); - const resumeDirectories = await readdir(resumeDataDirectory); - for (const slug of resumeDirectories) { - const resumeFilePath = getResumeFilePath(getResumeDirectory(slug)); - const resumeFileContents = await readJson(resumeFilePath); - const { date } = resumeFileContents as Resume; - await db.put([date, slug], buildResumeIndexValue(resumeFileContents)); - } - db.close(); + await rebuildIndex({ config: resumeContentConfig }); revalidatePath("/"); } diff --git a/websites/resume-builder/src/controller/actions/update.ts b/websites/resume-builder/src/controller/actions/update.ts index 5cd307f7..dab50222 100644 --- a/websites/resume-builder/src/controller/actions/update.ts +++ b/websites/resume-builder/src/controller/actions/update.ts @@ -1,18 +1,12 @@ "use server"; -import { rename, writeFile } from "fs-extra"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import parseResumeFormData from "../parseFormData"; import { ResumeFormState } from "../formState"; -import { - getResumeDirectory, - getResumeFilePath, -} from "../filesystemDirectories"; -import { Resume } from "../types"; -import getResumeDatabase from "../database"; -import buildResumeIndexValue from "../buildIndexValue"; -import createDefaultSlug from "../createSlug"; +import type { Resume, ResumeEntryKey } from "../types"; +import { resumeContentConfig } from "../resumeContentConfig"; +import { updateContent } from "content-engine/content/updateContent"; import z from "zod"; export default async function updateResume( @@ -30,74 +24,24 @@ export default async function updateResume( }; } - const { - date, - slug, - company, - job, - address, - email, - github, - linkedin, - name, - phone, - skills, - website, - education, - experience, - projects, - } = validatedFields.data; + const { date, slug, ...rest } = validatedFields.data; - const currentResumeDirectory = getResumeDirectory(currentSlug); - const currentResumePath = getResumeFilePath(currentResumeDirectory); - - const finalSlug = slug || createDefaultSlug(validatedFields.data); + const finalSlug = slug || resumeContentConfig.createDefaultSlug!({ ...rest, date: date || currentDate }); const finalDate = date || currentDate; - const finalResumeDirectory = getResumeDirectory(finalSlug); - - const willRename = currentResumeDirectory !== finalResumeDirectory; - const willChangeDate = date && currentDate !== date; + const currentIndexKey: ResumeEntryKey = [currentDate, currentSlug]; - const data: Resume = { - company, - job, - date: finalDate, - address, - email, - github, - linkedin, - education, - experience, - name, - phone, - projects, - skills, - website, - }; + const data: Resume = { ...rest, date: finalDate }; - if (willRename) { - await rename(currentResumeDirectory, finalResumeDirectory); - await writeFile( - `${finalResumeDirectory}/resume.json`, - JSON.stringify(data), - ); - } else { - await writeFile(currentResumePath, JSON.stringify(data)); - } + await updateContent({ + config: resumeContentConfig, + slug: finalSlug, + currentSlug, + currentIndexKey, + data, + commitMessage: `Update resume: ${finalSlug}`, + }); - const db = getResumeDatabase(); - - try { - if (willRename || willChangeDate) { - db.remove([currentDate, currentSlug]); - } - db.put([finalDate, finalSlug], buildResumeIndexValue(data)); - } catch { - return { message: "Failed to write resume to index" }; - } finally { - db.close(); - } - if (willRename) { + if (currentSlug !== finalSlug) { revalidatePath("/resume/" + currentSlug); } revalidatePath("/resume/" + finalSlug); diff --git a/websites/resume-builder/src/controller/data/read.ts b/websites/resume-builder/src/controller/data/read.ts index 88e0ad08..904e7df7 100644 --- a/websites/resume-builder/src/controller/data/read.ts +++ b/websites/resume-builder/src/controller/data/read.ts @@ -1,10 +1,10 @@ -import { readJson } from "fs-extra"; -import { - getResumeDirectory, - getResumeFilePath, -} from "../filesystemDirectories"; -import { Resume } from "../types"; +import { readContentFile } from "content-engine/content/readContentFile"; +import { resumeContentConfig } from "../resumeContentConfig"; +import type { Resume, ResumeEntryKey, ResumeEntryValue } from "../types"; export default async function getResumeBySlug(slug: string): Promise { - return readJson(getResumeFilePath(getResumeDirectory(slug))); + return readContentFile({ + config: resumeContentConfig, + slug, + }); } diff --git a/websites/resume-builder/src/controller/data/readIndex.ts b/websites/resume-builder/src/controller/data/readIndex.ts index 20843a91..6e3c5fa7 100644 --- a/websites/resume-builder/src/controller/data/readIndex.ts +++ b/websites/resume-builder/src/controller/data/readIndex.ts @@ -1,5 +1,6 @@ -import getResumeDatabase from "../database"; -import { ResumeEntry } from "../types"; +import { readContentIndex } from "content-engine/content/readContentIndex"; +import { resumeContentConfig } from "../resumeContentConfig"; +import type { ResumeEntry, ResumeEntryKey, ResumeEntryValue } from "../types"; export interface ReadResumeIndexResult { resumes: ResumeEntry[]; @@ -10,10 +11,16 @@ export default async function getResumes({ limit, offset, }: { limit?: number; offset?: number } = {}): Promise { - const db = getResumeDatabase(); - const resumes = db.getRange({ limit, offset, reverse: true }).asArray; - const totalResumes = db.getCount(); - const more = (offset || 0) + (limit || 0) < totalResumes; - db.close(); - return { resumes, more }; + const { entries, more } = await readContentIndex< + ResumeEntryValue, + ResumeEntryKey, + ResumeEntry + >({ + config: resumeContentConfig, + limit, + offset, + reverse: true, + map: ({ key, value }) => ({ key, value }), + }); + return { resumes: entries, more }; } diff --git a/websites/resume-builder/src/controller/database.ts b/websites/resume-builder/src/controller/database.ts deleted file mode 100644 index ed867138..00000000 --- a/websites/resume-builder/src/controller/database.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { open } from "lmdb"; -import { resumeIndexDirectory } from "./filesystemDirectories"; -import { ResumeEntryKey, ResumeEntryValue } from "./types"; - -export default function getResumeDatabase() { - return open({ - path: resumeIndexDirectory, - }); -} diff --git a/websites/resume-builder/src/controller/filesystemDirectories.ts b/websites/resume-builder/src/controller/filesystemDirectories.ts deleted file mode 100644 index 4fd4e2a6..00000000 --- a/websites/resume-builder/src/controller/filesystemDirectories.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { resolve, join } from "path"; - -import { contentDirectory } from "content-engine/fs/getContentDirectory"; - -export const resumesBaseDirectory = resolve(contentDirectory, "resumes"); - -export const resumeDataDirectory = resolve(resumesBaseDirectory, "data"); -export const resumeIndexDirectory = resolve(resumesBaseDirectory, "index"); - -export function getResumeDirectory(slug: string) { - return resolve(resumeDataDirectory, slug); -} - -export function getResumeFilePath(basePath: string) { - return basePath + "/resume.json"; -} - -export function getResumeUploadPath( - contentDirectory: string, - slug: string, - filename: string, -) { - return join(contentDirectory, "uploads", "resume", slug, "uploads", filename); -} diff --git a/websites/resume-builder/src/controller/formState.ts b/websites/resume-builder/src/controller/formState.ts index fbcd040c..d499edd8 100644 --- a/websites/resume-builder/src/controller/formState.ts +++ b/websites/resume-builder/src/controller/formState.ts @@ -1,11 +1,22 @@ export interface ResumeFormErrors extends Record { - description?: string[]; - name?: string[]; + company?: string[]; + job?: string[]; date?: string[]; slug?: string[]; + skills?: string[]; + name?: string[]; + phone?: string[]; + email?: string[]; + address?: string[]; + github?: string[]; + linkedin?: string[]; + website?: string[]; + education?: string[]; + experience?: string[]; + projects?: string[]; } export type ResumeFormState = { - errors?: ResumeFormErrors; + errors: ResumeFormErrors; message: string; }; diff --git a/websites/resume-builder/src/controller/resumeContentConfig.ts b/websites/resume-builder/src/controller/resumeContentConfig.ts new file mode 100644 index 00000000..d0ea30d1 --- /dev/null +++ b/websites/resume-builder/src/controller/resumeContentConfig.ts @@ -0,0 +1,19 @@ +import type { ContentTypeConfig } from "content-engine/content/types"; +import type { Resume, ResumeEntryKey, ResumeEntryValue } from "./types"; +import buildResumeIndexValue from "./buildIndexValue"; +import createDefaultSlug from "./createSlug"; + +export const resumeContentConfig: ContentTypeConfig< + Resume, + ResumeEntryValue, + ResumeEntryKey +> = { + contentType: "resume", + dataDirectory: "resumes/data", + indexDirectory: "resumes/index", + dataFilename: "resume.json", + buildIndexValue: buildResumeIndexValue, + buildIndexKey: (slug: string, data: Resume): ResumeEntryKey => [data.date, slug], + createDefaultSlug: (data: Resume) => + createDefaultSlug({ company: data.company, job: data.job }), +}; diff --git a/websites/resume-builder/tsconfig.json b/websites/resume-builder/tsconfig.json index c1334095..50eac54d 100644 --- a/websites/resume-builder/tsconfig.json +++ b/websites/resume-builder/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2017", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -11,7 +15,7 @@ "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, - "jsx": "preserve", + "jsx": "react-jsx", "incremental": true, "plugins": [ { @@ -19,9 +23,20 @@ } ], "paths": { - "@/*": ["./src/*"] + "@/*": [ + "./src/*" + ] } }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules", + "cypress" + ] }