diff --git a/flake.nix b/flake.nix index e12cdc8..7fc0357 100644 --- a/flake.nix +++ b/flake.nix @@ -29,7 +29,13 @@ alias formatsql="${pkgs.pgformatter}/bin/pg_format -s 2 -f 2 -U 2 -i db/migrations/*.sql" ''; }; - web = pkgs.mkShell { packages = with pkgs; [ nodejs_22 ]; }; + web = pkgs.mkShell { + packages = with pkgs; [ nodejs_22 ]; + shellHook = '' + export PLAYWRIGHT_BROWSERS_PATH=${pkgs.playwright-driver.browsers} + export PLAYWRIGHT_SKIP_VALIDATE_HOST_REQUIREMENTS=true + ''; + }; } ); diff --git a/nix/package.nix b/nix/package.nix index f1d65c8..333df77 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -13,9 +13,7 @@ let web = buildNpmPackage { name = "web-app"; src = ../web-app; - npmDeps = importNpmLock { - npmRoot = ../web-app; - }; + npmDeps = importNpmLock { npmRoot = ../web-app; }; npmConfigHook = importNpmLock.npmConfigHook; npmFlags = [ "--legacy-peer-deps" ]; installPhase = '' diff --git a/rest-api/db/migrations/20240830062353_optional_publication_date.sql b/rest-api/db/migrations/20240830062353_optional_publication_date.sql new file mode 100644 index 0000000..855351a --- /dev/null +++ b/rest-api/db/migrations/20240830062353_optional_publication_date.sql @@ -0,0 +1,8 @@ +-- migrate:up +ALTER TABLE internal.article + ALTER COLUMN publication_date DROP NOT NULL; + +-- migrate:down +ALTER TABLE internal.article + ALTER COLUMN publication_date SET NOT NULL; + diff --git a/rest-api/db/migrations/20240830112106_website_view_publication_status.sql b/rest-api/db/migrations/20240830112106_website_view_publication_status.sql new file mode 100644 index 0000000..a632944 --- /dev/null +++ b/rest-api/db/migrations/20240830112106_website_view_publication_status.sql @@ -0,0 +1,35 @@ +-- migrate:up +CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON +) AS +SELECT + id, + user_id, + content_type, + title, + created_at, + last_modified_at, + last_modified_by, + title_search, + is_published -- New column +FROM + internal.website; + +GRANT SELECT, UPDATE, DELETE ON api.website TO authenticated_user; + +-- migrate:down +DROP VIEW api.website; + +CREATE OR REPLACE VIEW api.website WITH ( security_invoker = ON +) AS +SELECT + id, + user_id, + content_type, + title, + created_at, + last_modified_at, + last_modified_by, + title_search +FROM + internal.website; + diff --git a/web-app/package-lock.json b/web-app/package-lock.json index 6506474..de824cf 100644 --- a/web-app/package-lock.json +++ b/web-app/package-lock.json @@ -15,6 +15,7 @@ "marked-highlight": "2.1.4" }, "devDependencies": { + "@playwright/test": "1.40.0", "@sveltejs/adapter-auto": "3.2.4", "@sveltejs/adapter-node": "5.2.2", "@sveltejs/kit": "2.5.22", @@ -515,6 +516,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.40.0.tgz", + "integrity": "sha512-PdW+kn4eV99iP5gxWNSDQCbhMaDVej+RXL5xr6t04nbKLCBwYtA046t7ofoczHOm8u6c+45hpDKQVZqtqwkeQg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.40.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.25", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", @@ -2193,6 +2210,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.40.0.tgz", + "integrity": "sha512-gyHAgQjiDf1m34Xpwzaqb76KgfzYrhK7iih+2IzcOCoZWr/8ZqmdBw+t0RU85ZmfJMgtgAiNtBQ/KS2325INXw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.40.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.40.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.40.0.tgz", + "integrity": "sha512-fvKewVJpGeca8t0ipM56jkVSU6Eo0RmFvQ/MaCQNDYm+sdvKkMBBWTE1FdeMqIdumRaXXjZChWHvIzCGM/tA/Q==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.4.40", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.40.tgz", diff --git a/web-app/package.json b/web-app/package.json index a3eea4b..7299d1b 100644 --- a/web-app/package.json +++ b/web-app/package.json @@ -6,12 +6,14 @@ "dev": "vite dev", "build": "vite build", "preview": "vite preview", + "test": "playwright test", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "lint": "prettier --check .", "format": "prettier --write ." }, "devDependencies": { + "@playwright/test": "1.40.0", "@sveltejs/adapter-auto": "3.2.4", "@sveltejs/adapter-node": "5.2.2", "@sveltejs/kit": "2.5.22", diff --git a/web-app/playwright.config.ts b/web-app/playwright.config.ts new file mode 100644 index 0000000..5b9ccdb --- /dev/null +++ b/web-app/playwright.config.ts @@ -0,0 +1,12 @@ +import { type PlaywrightTestConfig } from "@playwright/test"; + +const config: PlaywrightTestConfig = { + webServer: { + command: "npm run build && npm run preview", + port: 4173 + }, + testDir: "tests", + testMatch: /(.+\.)?(test|spec)\.ts/ +}; + +export default config; diff --git a/web-app/src/lib/server/utils.ts b/web-app/src/lib/server/utils.ts index 09ecf6f..e745aeb 100644 --- a/web-app/src/lib/server/utils.ts +++ b/web-app/src/lib/server/utils.ts @@ -1,3 +1,5 @@ import { dev } from "$app/environment"; -export const API_BASE_PREFIX = dev ? "http://localhost:3000" : `${process.env.ORIGIN}/api`; +export const API_BASE_PREFIX = dev + ? "http://localhost:3000" + : `${process.env.ORIGIN ? `${process.env.ORIGIN}/api` : "http://localhost:3000"}`; diff --git a/web-app/src/routes/(authenticated)/+page.server.ts b/web-app/src/routes/(authenticated)/+page.server.ts index 3c8679b..ead6df1 100644 --- a/web-app/src/routes/(authenticated)/+page.server.ts +++ b/web-app/src/routes/(authenticated)/+page.server.ts @@ -1,5 +1,7 @@ import type { Actions, PageServerLoad } from "./$types"; import { API_BASE_PREFIX } from "$lib/server/utils"; +import { rm } from "node:fs/promises"; +import { join } from "node:path"; export const load: PageServerLoad = async ({ fetch, cookies, url, locals }) => { const searchQuery = url.searchParams.get("website_search_query"); @@ -130,6 +132,11 @@ export const actions: Actions = { return { success: false, message: response.message }; } + await rm(join("/", "var", "www", "archtika-websites", data.get("id") as string), { + recursive: true, + force: true + }); + return { success: true, message: "Successfully deleted website" }; } }; diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts index 9e64d84..d0f4cf9 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.server.ts @@ -49,22 +49,27 @@ export const actions: Actions = { const data = await request.formData(); const faviconFile = data.get("favicon") as File; + const headers: Record = { + "Content-Type": "application/octet-stream", + Authorization: `Bearer ${cookies.get("session_token")}`, + Accept: "application/vnd.pgrst.object+json", + "X-Website-Id": params.websiteId + }; + + if (faviconFile) { + headers["X-Mimetype"] = faviconFile.type; + headers["X-Original-Filename"] = faviconFile.name; + } + const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - Authorization: `Bearer ${cookies.get("session_token")}`, - Accept: "application/vnd.pgrst.object+json", - "X-Website-Id": params.websiteId, - "X-Mimetype": faviconFile.type, - "X-Original-Filename": faviconFile.name - }, - body: await faviconFile.arrayBuffer() + headers, + body: faviconFile ? await faviconFile.arrayBuffer() : null }); const uploadedImage = await uploadedImageData.json(); - if (!uploadedImageData.ok && faviconFile.size > 0) { + if (!uploadedImageData.ok && (faviconFile?.size ?? 0 > 0)) { return { success: false, message: uploadedImage.message }; } @@ -95,22 +100,27 @@ export const actions: Actions = { const data = await request.formData(); const logoImage = data.get("logo-image") as File; + const headers: Record = { + "Content-Type": "application/octet-stream", + Authorization: `Bearer ${cookies.get("session_token")}`, + Accept: "application/vnd.pgrst.object+json", + "X-Website-Id": params.websiteId + }; + + if (logoImage) { + headers["X-Mimetype"] = logoImage.type; + headers["X-Original-Filename"] = logoImage.name; + } + const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - Authorization: `Bearer ${cookies.get("session_token")}`, - Accept: "application/vnd.pgrst.object+json", - "X-Website-Id": params.websiteId, - "X-Mimetype": logoImage.type, - "X-Original-Filename": logoImage.name - }, - body: await logoImage.arrayBuffer() + headers, + body: logoImage ? await logoImage.arrayBuffer() : null }); const uploadedImage = await uploadedImageData.json(); - if (!uploadedImageData.ok && logoImage.size > 0) { + if (!uploadedImageData.ok && (logoImage?.size ?? 0 > 0)) { return { success: false, message: uploadedImage.message }; } diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte index 1126f4a..c8046c2 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/+page.svelte @@ -123,12 +123,7 @@
{#if data.header.logo_image} diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.server.ts index d96ec34..023ad18 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/articles/[articleId]/+page.server.ts @@ -34,22 +34,27 @@ export const actions: Actions = { const data = await request.formData(); const coverFile = data.get("cover-image") as File; + const headers: Record = { + "Content-Type": "application/octet-stream", + Authorization: `Bearer ${cookies.get("session_token")}`, + Accept: "application/vnd.pgrst.object+json", + "X-Website-Id": params.websiteId + }; + + if (coverFile) { + headers["X-Mimetype"] = coverFile.type; + headers["X-Original-Filename"] = coverFile.name; + } + const uploadedImageData = await fetch(`${API_BASE_PREFIX}/rpc/upload_file`, { method: "POST", - headers: { - "Content-Type": "application/octet-stream", - Authorization: `Bearer ${cookies.get("session_token")}`, - Accept: "application/vnd.pgrst.object+json", - "X-Website-Id": params.websiteId, - "X-Mimetype": coverFile.type, - "X-Original-Filename": coverFile.name - }, - body: await coverFile.arrayBuffer() + headers, + body: coverFile ? await coverFile.arrayBuffer() : null }); const uploadedImage = await uploadedImageData.json(); - if (!uploadedImageData.ok && coverFile.size > 0) { + if (!uploadedImageData.ok && (coverFile?.size ?? 0 > 0)) { return { success: false, message: uploadedImage.message }; } diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts index d5ea224..7a0a232 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.server.ts @@ -54,6 +54,22 @@ export const actions: Actions = { const websiteOverview = await websiteOverviewData.json(); generateStaticFiles(websiteOverview, false); + const res = await fetch(`${API_BASE_PREFIX}/website?id=eq.${params.websiteId}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${cookies.get("session_token")}` + }, + body: JSON.stringify({ + is_published: true + }) + }); + + if (!res.ok) { + const response = await res.json(); + return { success: false, message: response.message }; + } + return { success: true, message: "Successfully published website" }; } }; diff --git a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte index 23c4be9..a0f8fd7 100644 --- a/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte +++ b/web-app/src/routes/(authenticated)/website/[websiteId]/publish/+page.svelte @@ -5,6 +5,8 @@ import type { ActionData, PageServerData } from "./$types"; const { data, form }: { data: PageServerData; form: ActionData } = $props(); + + const prodWebsiteUrl = data.websitePreviewUrl.replace("/previews", ""); @@ -28,5 +30,18 @@
+ + {#if data.website.is_published} +
+

+ Publication status +

+

+ Your website is published at: +
+ {prodWebsiteUrl} +

+
+ {/if} diff --git a/web-app/tests/account.spec.ts b/web-app/tests/account.spec.ts new file mode 100644 index 0000000..34a0cb9 --- /dev/null +++ b/web-app/tests/account.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "@playwright/test"; + +test("Register", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Register" }).click(); + await page.getByLabel("Username:").click(); + await page.getByLabel("Username:").fill("archtika-test"); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByText("Successfully registered, you")).toBeVisible(); +}); + +test("Login", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Login" }).click(); + await page.getByLabel("Username:").click(); + await page.getByLabel("Username:").fill("archtika-test"); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); +}); + +test("Logout", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Login" }).click(); + await page.getByLabel("Username:").click(); + await page.getByLabel("Username:").fill("archtika-test"); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await page.getByRole("link", { name: "Account" }).click(); + await page.getByRole("button", { name: "Logout" }).click(); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); +}); + +test("Delete account", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Login" }).click(); + await page.getByLabel("Username:").click(); + await page.getByLabel("Username:").fill("archtika-test"); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible(); + await page.getByRole("link", { name: "Account" }).click(); + await page.getByRole("button", { name: "Delete account" }).click(); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page + .locator("#delete-account-modal") + .getByRole("button", { name: "Delete account" }) + .click(); + await expect(page.getByRole("heading", { name: "Login" })).toBeVisible(); +}); + +test("Register after account deletion", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "Register" }).click(); + await page.getByLabel("Username:").click(); + await page.getByLabel("Username:").fill("archtika-test"); + await page.getByLabel("Password:").click(); + await page.getByLabel("Password:").fill("T3stuser??!!"); + await page.getByRole("button", { name: "Submit" }).click(); + await expect(page.getByText("Successfully registered, you")).toBeVisible(); +});