First commit
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
.vscode/settings.json
|
||||
43
docker-compose.yml
Normal file
43
docker-compose.yml
Normal file
@@ -0,0 +1,43 @@
|
||||
version: "3.4"
|
||||
|
||||
services:
|
||||
shopping-lists:
|
||||
build: shopping-lists
|
||||
image: shopping-lists
|
||||
restart: "no"
|
||||
volumes:
|
||||
- ./shopping-lists/:/app
|
||||
ports:
|
||||
- 7777:7777
|
||||
depends_on:
|
||||
- database
|
||||
- flyway
|
||||
env_file:
|
||||
- project.env
|
||||
|
||||
database:
|
||||
container_name: database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d
|
||||
image: postgres:14.1
|
||||
restart: "no"
|
||||
env_file:
|
||||
- project.env
|
||||
|
||||
flyway:
|
||||
image: flyway/flyway:8.4.0-alpine
|
||||
depends_on:
|
||||
- database
|
||||
volumes:
|
||||
- .:/flyway/sql
|
||||
command: -connectRetries=60 -baselineOnMigrate=true migrate
|
||||
env_file:
|
||||
- project.env
|
||||
|
||||
e2e-playwright:
|
||||
entrypoint: "/bin/true" # Prevent startup on docker-compose up
|
||||
build: e2e-playwright
|
||||
image: e2e-playwright
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- shopping-lists
|
||||
volumes:
|
||||
- ./e2e-playwright/tests:/e2e-playwright/tests
|
||||
10
e2e-playwright/Dockerfile
Normal file
10
e2e-playwright/Dockerfile
Normal file
@@ -0,0 +1,10 @@
|
||||
FROM mcr.microsoft.com/playwright:v1.24.2-focal
|
||||
|
||||
COPY . /e2e-playwright
|
||||
|
||||
WORKDIR /e2e-playwright
|
||||
|
||||
RUN npm install
|
||||
RUN npx playwright install chrome
|
||||
|
||||
CMD [ "npx", "playwright", "test", "--reporter=list" ]
|
||||
8
e2e-playwright/package.json
Normal file
8
e2e-playwright/package.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"name": "e2e-playwright-in-docker",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"playwright": "^1.24.2",
|
||||
"@playwright/test": "^1.24.2"
|
||||
}
|
||||
}
|
||||
20
e2e-playwright/playwright.config.js
Normal file
20
e2e-playwright/playwright.config.js
Normal file
@@ -0,0 +1,20 @@
|
||||
module.exports = {
|
||||
timeout: 10000,
|
||||
retries: 0,
|
||||
reporter: "list",
|
||||
workers: 5,
|
||||
use: {
|
||||
baseURL: "http://localhost:7777",
|
||||
headless: true,
|
||||
ignoreHTTPSErrors: true,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "e2e-headless-chrome",
|
||||
use: {
|
||||
browserName: "chromium",
|
||||
channel: "chrome",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
6
e2e-playwright/tests/hello-world.spec.js
Normal file
6
e2e-playwright/tests/hello-world.spec.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const { test, expect } = require("@playwright/test");
|
||||
|
||||
test("Server responds with the text 'Hello world!'", async ({ page }) => {
|
||||
const response = await page.goto("/");
|
||||
expect(await response.text()).toBe("Hello world!");
|
||||
});
|
||||
12
flyway/sql/V1___initial_schema.sql
Normal file
12
flyway/sql/V1___initial_schema.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
CREATE TABLE shopping_lists (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN DEFAULT TRUE
|
||||
);
|
||||
|
||||
CREATE TABLE shopping_list_items (
|
||||
id SERIAL PRIMARY KEY,
|
||||
shopping_list_id INTEGER REFERENCES shopping_lists(id),
|
||||
name TEXT NOT NULL,
|
||||
collected BOOLEAN DEFAULT FALSE
|
||||
);
|
||||
16
project.env
Normal file
16
project.env
Normal file
@@ -0,0 +1,16 @@
|
||||
# Database configuration for PostgreSQL (running in container called "database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d")
|
||||
POSTGRES_USER=username
|
||||
POSTGRES_PASSWORD=password
|
||||
POSTGRES_DB=database
|
||||
|
||||
# Database configuration for Flyway (used for database migrations)
|
||||
FLYWAY_USER=username
|
||||
FLYWAY_PASSWORD=password
|
||||
FLYWAY_URL=jdbc:postgresql://database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d:5432/database
|
||||
|
||||
# Database configuration for Deno's PostgreSQL driver
|
||||
PGUSER=username
|
||||
PGPASSWORD=password
|
||||
PGHOST=database-p1-e8774b8e-c7a9-4cce-a779-3b59be02206d
|
||||
PGPORT=5432
|
||||
PGDATABASE=database
|
||||
11
shopping-lists/Dockerfile
Normal file
11
shopping-lists/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM denoland/deno:alpine-1.26.2
|
||||
|
||||
EXPOSE 7777
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN deno cache deps.js
|
||||
|
||||
CMD [ "run", "--unstable", "--watch", "--allow-net", "--allow-read", "--allow-env", "--no-check", "app.js" ]
|
||||
41
shopping-lists/app.js
Normal file
41
shopping-lists/app.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { serve } from "./deps.js";
|
||||
import { configure } from "./deps.js";
|
||||
import * as listController from "./controllers/listController.js";
|
||||
import * as itemController from "./controllers/itemController.js";
|
||||
import * as mainController from "./controllers/mainController.js";
|
||||
|
||||
configure({
|
||||
views: `${Deno.cwd()}/views/`,
|
||||
});
|
||||
|
||||
const handleRequest = async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/" && request.method === "GET") {
|
||||
return mainController.showMain(request);
|
||||
} else if (url.pathname === "/lists" && request.method === "GET") {
|
||||
return await listController.viewLists(request);
|
||||
} else if (url.pathname === "/lists" && request.method === "POST") {
|
||||
return await listController.addList(request);
|
||||
} else if (url.pathname.match("lists/[0-9]+") && request.method === "GET") {
|
||||
return await listController.viewList(request);
|
||||
} else if (
|
||||
url.pathname.match("lists/[0-9]+/deactivate") &&
|
||||
request.method === "POST"
|
||||
) {
|
||||
return await listController.deactivateList(request);
|
||||
} else if (
|
||||
url.pathname.match("lists/[0-9]+/items/[0-9]+/collect") &&
|
||||
request.method === "POST"
|
||||
) {
|
||||
return await itemController.markItemCollected(request);
|
||||
} else if (
|
||||
url.pathname.match("lists/[0-9]+/items") &&
|
||||
request.method === "POST"
|
||||
) {
|
||||
return await itemController.addItemToList(request);
|
||||
} else {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
};
|
||||
|
||||
serve(handleRequest, { port: 7777 });
|
||||
22
shopping-lists/controllers/itemController.js
Normal file
22
shopping-lists/controllers/itemController.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as itemService from "../services/itemService.js";
|
||||
import * as requestUtils from "../utils/requestUtils.js";
|
||||
|
||||
const markItemCollected = async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const urlParts = url.pathname.split("/");
|
||||
await itemService.markCollected(urlParts[4], urlParts[2]);
|
||||
|
||||
return requestUtils.redirectTo(`/lists/${urlParts[4]}`);
|
||||
};
|
||||
|
||||
const addItemToList = async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const urlParts = url.pathname.split("/");
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name");
|
||||
await itemService.addItem(urlParts[2], name);
|
||||
|
||||
return requestUtils.redirectTo(`/lists/${urlParts[2]}`);
|
||||
};
|
||||
|
||||
export { markItemCollected, addItemToList };
|
||||
45
shopping-lists/controllers/listController.js
Normal file
45
shopping-lists/controllers/listController.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { renderFile } from "../deps.js";
|
||||
import * as listService from "../services/listService.js";
|
||||
import * as requestUtils from "../utils/requestUtils.js";
|
||||
|
||||
const responseDetails = {
|
||||
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||
};
|
||||
|
||||
const addList = async (request) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get("name");
|
||||
|
||||
await listService.create(name);
|
||||
|
||||
return requestUtils.redirectTo("/lists");
|
||||
};
|
||||
|
||||
const viewList = async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const urlParts = url.pathname.split("/");
|
||||
|
||||
const data = {
|
||||
items: await listService.findById(urlParts[2]),
|
||||
};
|
||||
|
||||
return new Response(await renderFile("list.eta", data), responseDetails);
|
||||
};
|
||||
|
||||
const viewLists = async (_request) => {
|
||||
const data = {
|
||||
lists: await listService.findAllLists(),
|
||||
};
|
||||
|
||||
return new Response(await renderFile("lists.eta", data), responseDetails);
|
||||
};
|
||||
|
||||
const deactivateList = async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const urlParts = url.pathname.split("/");
|
||||
await listService.deactivateByID(urlParts[2]);
|
||||
|
||||
return requestUtils.redirectTo("/lists");
|
||||
};
|
||||
|
||||
export { addList, viewList, viewLists, deactivateList };
|
||||
17
shopping-lists/controllers/mainController.js
Normal file
17
shopping-lists/controllers/mainController.js
Normal file
@@ -0,0 +1,17 @@
|
||||
import { renderFile } from "../deps.js";
|
||||
import * as mainService from "../services/mainService.js";
|
||||
|
||||
const responseDetails = {
|
||||
headers: { "Content-Type": "text/html;charset=UTF-8" },
|
||||
};
|
||||
|
||||
const showMain = async (_request) => {
|
||||
const data = {
|
||||
listCount: await mainService.checkList(),
|
||||
itemCount: await mainService.checkItem(),
|
||||
};
|
||||
|
||||
return new Response(await renderFile("main.eta", data), responseDetails);
|
||||
};
|
||||
|
||||
export { showMain };
|
||||
38
shopping-lists/database/database.js
Normal file
38
shopping-lists/database/database.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Pool } from "../deps.js";
|
||||
|
||||
const CONCURRENT_CONNECTIONS = 2;
|
||||
let connectionPool;
|
||||
if (Deno.env.get("DATABASE_URL")) {
|
||||
connectionPool = new Pool(Deno.env.get("DATABASE_URL"), CONCURRENT_CONNECTIONS);
|
||||
} else {
|
||||
connectionPool = new Pool({}, CONCURRENT_CONNECTIONS);
|
||||
}
|
||||
|
||||
const executeQuery = async (query, params) => {
|
||||
const response = {};
|
||||
let client;
|
||||
|
||||
try {
|
||||
client = await connectionPool.connect();
|
||||
const result = await client.queryObject(query, params);
|
||||
if (result.rows) {
|
||||
response.rows = result.rows;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
response.error = e;
|
||||
} finally {
|
||||
if (client) {
|
||||
try {
|
||||
await client.release();
|
||||
} catch (e) {
|
||||
console.log("Unable to release database connection.");
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
};
|
||||
|
||||
export { executeQuery };
|
||||
3
shopping-lists/deps.js
Normal file
3
shopping-lists/deps.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { serve } from "https://deno.land/std@0.160.0/http/server.ts";
|
||||
export { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
|
||||
export { configure, renderFile } from "https://deno.land/x/eta@v1.12.3/mod.ts";
|
||||
38
shopping-lists/fly.toml
Normal file
38
shopping-lists/fly.toml
Normal file
@@ -0,0 +1,38 @@
|
||||
# fly.toml file generated for shopper on 2022-12-27T19:33:40+02:00
|
||||
|
||||
app = "shopper"
|
||||
kill_signal = "SIGINT"
|
||||
kill_timeout = 5
|
||||
processes = []
|
||||
|
||||
[env]
|
||||
|
||||
[experimental]
|
||||
allowed_public_ports = []
|
||||
auto_rollback = true
|
||||
|
||||
[[services]]
|
||||
http_checks = []
|
||||
internal_port = 7777
|
||||
processes = ["app"]
|
||||
protocol = "tcp"
|
||||
script_checks = []
|
||||
[services.concurrency]
|
||||
hard_limit = 25
|
||||
soft_limit = 20
|
||||
type = "connections"
|
||||
|
||||
[[services.ports]]
|
||||
force_https = true
|
||||
handlers = ["http"]
|
||||
port = 80
|
||||
|
||||
[[services.ports]]
|
||||
handlers = ["tls", "http"]
|
||||
port = 443
|
||||
|
||||
[[services.tcp_checks]]
|
||||
grace_period = "1s"
|
||||
interval = "15s"
|
||||
restart_limit = 0
|
||||
timeout = "2s"
|
||||
23
shopping-lists/services/itemService.js
Normal file
23
shopping-lists/services/itemService.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import { executeQuery } from "../database/database.js";
|
||||
|
||||
const markCollected = async (id, shopping_list_id) => {
|
||||
await executeQuery(
|
||||
"UPDATE shopping_list_items SET collected = true WHERE id = $id AND shopping_list_id = $shopping_list_id;",
|
||||
{
|
||||
id: id,
|
||||
shopping_list_id: shopping_list_id,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const addItem = async (shopping_list_id, name) => {
|
||||
await executeQuery(
|
||||
"INSERT INTO shopping_list_items (shopping_list_id, name) VALUES ($shopping_list_id, $name);",
|
||||
{
|
||||
shopping_list_id: shopping_list_id,
|
||||
name: name,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export { markCollected, addItem };
|
||||
41
shopping-lists/services/listService.js
Normal file
41
shopping-lists/services/listService.js
Normal file
@@ -0,0 +1,41 @@
|
||||
import { executeQuery } from "../database/database.js";
|
||||
|
||||
const deactivateByID = async (id) => {
|
||||
await executeQuery(
|
||||
"UPDATE shopping_lists SET active = false WHERE id = $id;",
|
||||
{
|
||||
id: id,
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const create = async (name) => {
|
||||
await executeQuery("INSERT INTO shopping_lists (name) VALUES ($name);", {
|
||||
name: name,
|
||||
});
|
||||
};
|
||||
|
||||
const findAllLists = async () => {
|
||||
const result = await executeQuery(
|
||||
"SELECT * FROM shopping_lists WHERE active = true;"
|
||||
);
|
||||
|
||||
return result.rows;
|
||||
};
|
||||
|
||||
const findById = async (shopping_list_id) => {
|
||||
const result = await executeQuery(
|
||||
"SELECT * FROM shopping_list_items WHERE shopping_list_id = $shopping_list_id;",
|
||||
{
|
||||
shopping_list_id: shopping_list_id,
|
||||
}
|
||||
);
|
||||
|
||||
if (result.rows && result.rows.length > 0) {
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
return { id: 0, name: "Unknown" };
|
||||
};
|
||||
|
||||
export { deactivateByID, create, findAllLists, findById };
|
||||
15
shopping-lists/services/mainService.js
Normal file
15
shopping-lists/services/mainService.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { executeQuery } from "../database/database.js";
|
||||
|
||||
const checkList = async () => {
|
||||
const result = await executeQuery("SELECT COUNT(*) FROM shopping_lists;");
|
||||
return result.rows[0].count;
|
||||
};
|
||||
|
||||
const checkItem = async () => {
|
||||
const result = await executeQuery(
|
||||
"SELECT COUNT(*) FROM shopping_list_items;"
|
||||
);
|
||||
return result.rows[0].count;
|
||||
};
|
||||
|
||||
export { checkList, checkItem };
|
||||
10
shopping-lists/utils/requestUtils.js
Normal file
10
shopping-lists/utils/requestUtils.js
Normal file
@@ -0,0 +1,10 @@
|
||||
const redirectTo = (path) => {
|
||||
return new Response(`Redirecting to ${path}.`, {
|
||||
status: 303,
|
||||
headers: {
|
||||
"Location": path,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export { redirectTo };
|
||||
15
shopping-lists/views/layouts/layout.eta
Normal file
15
shopping-lists/views/layouts/layout.eta
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Shared shopping lists</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/papercss@1.8.1/dist/paper.min.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<meta charset="utf-8">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container paper">
|
||||
<%~ it.body %>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
44
shopping-lists/views/list.eta
Normal file
44
shopping-lists/views/list.eta
Normal file
@@ -0,0 +1,44 @@
|
||||
<% layout("./layouts/layout.eta") %>
|
||||
<h1><%= it.list.name %></h1>
|
||||
|
||||
<h2>Add an item</h2>
|
||||
|
||||
<form method="POST" action="/lists/<%= it.list.id %>/items">
|
||||
Name: <input type="text" name="name" />
|
||||
<input type="submit" value="Add!" />
|
||||
</form>
|
||||
|
||||
<h2>Active shopping lists</h2>
|
||||
|
||||
<table>
|
||||
<tr>
|
||||
<th>Items</th>
|
||||
<th>Collected</th>
|
||||
</tr>
|
||||
<%
|
||||
it.items.sort((a, b) => {
|
||||
if (a.collected === b.collected) {
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
return a.collected ? 1 : -1;
|
||||
});
|
||||
it.items.forEach((item) => {
|
||||
%>
|
||||
<tr>
|
||||
<th>
|
||||
<% if (item.collected) { %>
|
||||
<del><%= item.name %></del>
|
||||
<% } else { %>
|
||||
<%= item.name %>
|
||||
<% } %>
|
||||
</th>
|
||||
<th>
|
||||
<form method="POST" action="/lists/<%= item.shopping_list_id %>/items/<%= item.id %>/collect">
|
||||
<input type="submit" value="Mark collected!" />
|
||||
</form>
|
||||
</th>
|
||||
</tr>
|
||||
<% }); %>
|
||||
</table>
|
||||
|
||||
<a href="/lists">Shopping lists</a>
|
||||
24
shopping-lists/views/lists.eta
Normal file
24
shopping-lists/views/lists.eta
Normal file
@@ -0,0 +1,24 @@
|
||||
<% layout("./layouts/layout.eta") %>
|
||||
<h1>Lists</h1>
|
||||
|
||||
<h2>Add a shopping list</h2>
|
||||
|
||||
<form method="POST">
|
||||
Name: <input type="text" name="name" />
|
||||
<input type="submit" value="Create!" />
|
||||
</form>
|
||||
|
||||
<h2>Active shopping lists</h2>
|
||||
|
||||
<ul>
|
||||
<% it.lists.forEach((list) => { %>
|
||||
<li>
|
||||
<a href="/lists/<%= list.id %>"><%= list.name %></a>
|
||||
<form method="POST" action="/lists/<%= list.id %>/deactivate">
|
||||
<input type="submit" value="Deactivate list!" />
|
||||
</form>
|
||||
</li>
|
||||
<% }); %>
|
||||
</ul>
|
||||
|
||||
<a href="/">Main page</a>
|
||||
19
shopping-lists/views/main.eta
Normal file
19
shopping-lists/views/main.eta
Normal file
@@ -0,0 +1,19 @@
|
||||
<% layout("./layouts/layout.eta") %>
|
||||
<h1>Main page</h1>
|
||||
|
||||
<ul>
|
||||
<% if (it.listCount !== 0) { %>
|
||||
<li>Shopping lists: <%= it.listCount %></li>
|
||||
<% } else { %>
|
||||
<li>No shopping lists yet.</li>
|
||||
<% } %>
|
||||
<% if (it.itemCount !== 0) { %>
|
||||
<li>Shopping list items: <%= it.itemCount %></li>
|
||||
<% } else { %>
|
||||
<li>No items yet.</li>
|
||||
<% } %>
|
||||
</ul>
|
||||
|
||||
<% if (it.listCount !== 0) { %>
|
||||
<a href="/lists">Lists</a>
|
||||
<% } %>
|
||||
Reference in New Issue
Block a user