Create Todo app
Summary: Ref T2687 Reviewers: #testers, kuba-orlik Reviewed By: #testers, kuba-orlik Subscribers: kuba-orlik, jenkins-user Maniphest Tasks: T2687 Differential Revision: https://hub.sealcode.org/D1339
This commit is contained in:
parent
b7cc271d97
commit
0c49effa9d
3
.gitignore
vendored
3
.gitignore
vendored
@ -6,6 +6,7 @@ tmp/
|
||||
*.sublime-workspace
|
||||
npm-debug.log
|
||||
node_modules
|
||||
docker_node_modules
|
||||
db
|
||||
coverage.html
|
||||
\#*
|
||||
@ -27,3 +28,5 @@ public/dist
|
||||
/hint-report/
|
||||
.vscode
|
||||
.env
|
||||
.npm_cache/
|
||||
.mozilla/
|
||||
|
@ -11,8 +11,9 @@ services:
|
||||
dockerfile: ./test.Dockerfile
|
||||
volumes:
|
||||
- ./:/opt/sealious-app/
|
||||
- ~/.npm_cacache:/opt/sealious-app/.npm_cacache
|
||||
user: ${UID:-1000}:${GID:-1000}
|
||||
- /tmp:/tmp
|
||||
- ./docker_node_modules:/opt/sealious-app/node_modules
|
||||
user: ${USER_ID:-1000}:${GROUP_ID:-1000}
|
||||
mailcatcher:
|
||||
image: schickling/mailcatcher:latest
|
||||
ports:
|
||||
|
@ -10,13 +10,16 @@ RUN apt update
|
||||
RUN apt install -y git
|
||||
RUN apt install -y tmux
|
||||
|
||||
# playwright deps
|
||||
RUN apt-get update&& apt-get install -y --no-install-recommends libasound2 libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libcairo2 libcups2 libdbus-1-3 libdrm2 libgbm1 libglib2.0-0 libnspr4 libnss3 libpango-1.0-0 libwayland-client0 libx11-6 libxcb1 libxcomposite1 libxdamage1 libxext6 libxfixes3 libxkbcommon0 libxrandr2 xvfb fonts-noto-color-emoji fonts-unifont libfontconfig1 libfreetype6 xfonts-cyrillic xfonts-scalable fonts-liberation fonts-ipafont-gothic fonts-wqy-zenhei fonts-tlwg-loma-otf fonts-freefont-ttf libcairo-gobject2 libdbus-glib-1-2 libgdk-pixbuf-2.0-0 libgtk-3-0 libharfbuzz0b libpangocairo-1.0-0 libx11-xcb1 libxcb-shm0 libxcursor1 libxi6 libxrender1 libxtst6 gstreamer1.0-libav gstreamer1.0-plugins-bad gstreamer1.0-plugins-base gstreamer1.0-plugins-good libegl1 libenchant-2-2 libepoxy0 libevdev2 libgles2 libglx0 libgstreamer-gl1.0-0 libgstreamer-plugins-base1.0-0 libgstreamer1.0-0 libgudev-1.0-0 libharfbuzz-icu0 libhyphen0 libicu67 libjpeg62-turbo liblcms2-2 libmanette-0.2-0 libnotify4 libopengl0 libopenjp2-7 libopus0 libpng16-16 libproxy1v5 libsecret-1-0 libsoup2.4-1 libwayland-egl1 libwayland-server0 libwebp6 libwebpdemux2 libwoff1 libxml2 libxslt1.1 libatomic1 libevent-2.1-7
|
||||
|
||||
RUN chmod +x /tini
|
||||
ENTRYPOINT ["/tini", "--"]
|
||||
|
||||
VOLUME $HOME
|
||||
WORKDIR $HOME
|
||||
|
||||
RUN npm install -g npm@latest
|
||||
RUN npm install -g npm@7
|
||||
RUN npm install -g @sealcode/sealgen
|
||||
|
||||
USER $UID:$GID
|
||||
|
@ -3,6 +3,7 @@ set -e
|
||||
|
||||
docker-compose down
|
||||
|
||||
npm install @sealcode/sealgen
|
||||
npx sealgen make-env
|
||||
cp secrets.example.json secrets.json
|
||||
|
||||
@ -11,17 +12,13 @@ SEALIOUS_BASE_URL=$(cat .base_url)
|
||||
export SEALIOUS_BASE_URL
|
||||
echo "PORT=$PORT" >> .env
|
||||
|
||||
# Create the npm cache directory if it isn't present yet. If it is not present, it will be created
|
||||
# when the docker image is being built with root:root as the owner.
|
||||
mkdir -p ~/.npm_cacache
|
||||
mkdir -p node_modules
|
||||
mkdir -p docker_node_modules
|
||||
|
||||
# https://github.com/docker/compose/issues/4725
|
||||
docker-compose build
|
||||
# Create .npm directory in the container, since it is not yet present and we need it for next step.
|
||||
docker-compose run --user="$UID" --rm --service-ports test mkdir -p /opt/sealious-app/.npm
|
||||
# Link the host-bound npm cache directory into the container's npm cache directory.
|
||||
docker-compose run --user="$UID" --rm --service-ports test ln -s /opt/sealious-app/.npm_cacache/ /opt/sealious-app/.npm/_cacache
|
||||
|
||||
docker-compose up -d db
|
||||
./npm.sh --no-TTY --user="$UID" ci && ./npm.sh --no-TTY --user="$UID" run build
|
||||
./npm.sh --no-TTY ci && ./npm.sh --no-TTY run build
|
||||
|
||||
rm -f log.html
|
||||
|
@ -1,5 +1,5 @@
|
||||
#!/bin/bash -xe
|
||||
|
||||
echo "starting sanity test..."
|
||||
export SEALIOUS_PORT=$PORT
|
||||
SEALIOUS_BASE_URL=$(cat .base_url)
|
||||
export SEALIOUS_BASE_URL
|
||||
@ -14,4 +14,3 @@ docker-compose run --user="$UID" \
|
||||
-e "SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL" \
|
||||
-e "SEALIOUS_SANITY=true" \
|
||||
test
|
||||
|
||||
|
9
mocha_root_hooks.js
Normal file
9
mocha_root_hooks.js
Normal file
@ -0,0 +1,9 @@
|
||||
import { LONG_TEST_TIMEOUT } from "./src/back/test_utils/webhint";
|
||||
import { closeBrowser } from "./src/back/test_utils/browser-creator";
|
||||
|
||||
exports.mochaHooks = {
|
||||
async afterAll() {
|
||||
this.timeout(LONG_TEST_TIMEOUT);
|
||||
await closeBrowser();
|
||||
},
|
||||
};
|
10
npm.sh
10
npm.sh
@ -1,12 +1,14 @@
|
||||
#!/usr/bin/env -S bash -x
|
||||
|
||||
|
||||
# the "--no-TTY" option is crucial - without it the output is not captured in Jenkins
|
||||
|
||||
docker-compose run \
|
||||
--rm \
|
||||
CONTAINER_ID=$(docker-compose run \
|
||||
-d \
|
||||
--service-ports \
|
||||
-e "SEALIOUS_MONGO_PORT=27017" \
|
||||
-e "SEALIOUS_MONGO_HOST=db" \
|
||||
test \
|
||||
npm "$@"
|
||||
npm "$@")
|
||||
|
||||
docker logs -f $CONTAINER_ID
|
||||
docker rm $CONTAINER_ID
|
||||
|
13665
package-lock.json
generated
13665
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
29
package.json
29
package.json
@ -5,29 +5,41 @@
|
||||
"main": "./dist/back/index.js",
|
||||
"scripts": {
|
||||
"start": "docker-compose up -d db && node .",
|
||||
"typecheck:back": "tsc --noEmit -p src/back",
|
||||
"typecheck:front": "tsc --noEmit -p src/front",
|
||||
"typecheck:back": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/back",
|
||||
"typecheck:front": "npx tsc --noEmit --target es6 --lib es2015,dom -p src/front",
|
||||
"build": "sealgen build",
|
||||
"watch": "multiple-scripts-tmux \"npm run typecheck:back -- --watch\" \"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps .\" \"npm run build -- --watch\" \"npm run typecheck:front -- --watch\" ",
|
||||
"test": "TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --require ts-node/register src/back/app.ts src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
|
||||
"watch": "multiple-scripts-tmux -p watch",
|
||||
"reset-db": "docker-compose down && docker-compose up -d",
|
||||
"install-test-deps": "npx playwright install firefox",
|
||||
"test": "npm run install-test-deps && TS_NODE_PROJECT='./src/back/tsconfig.json' mocha --recursive --timeout 20000 --require ts-node/register --require mocha_root_hooks.js src/back/app.ts src/back/**/*.test.ts src/back/**/**/*.test.ts src/back/**/**/**/*.test.ts src/back/**/**/**/**/*.test.ts",
|
||||
"coverage": "nyc npm run test --",
|
||||
"postinstall": "sealgen make-env",
|
||||
"test-reports": "docker-compose up -d && ./npm.sh run coverage -- --reporter xunit --reporter-option output=.xunit",
|
||||
"show-coverage": "npm run test-reports; xdg-open coverage/index.html"
|
||||
},
|
||||
"tmux-scripts": {
|
||||
"watch": [
|
||||
"npm run typecheck:back -- --watch",
|
||||
"npm run build -- --watch",
|
||||
"npm run typecheck:front -- --watch",
|
||||
"SEALIOUS_PORT=$SEALIOUS_PORT SEALIOUS_BASE_URL=$SEALIOUS_BASE_URL nodemon --enable-source-maps ."
|
||||
]
|
||||
},
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.12.10",
|
||||
"@hotwired/turbo": "^7.1.0",
|
||||
"@koa/router": "^10.1.1",
|
||||
"@playwright/test": "^1.36.1",
|
||||
"@sealcode/sealgen": "^0.8.39",
|
||||
"@sealcode/ts-predicates": "^0.4.0",
|
||||
"@sealcode/ts-predicates": "^0.4.3",
|
||||
"@types/kill-port": "^2.0.0",
|
||||
"hint": "^7.0.1",
|
||||
"locreq": "^2.0.2",
|
||||
"multiple-scripts-tmux": "^1.0.4",
|
||||
"nodemon": "^2.0.7",
|
||||
"sealious": "^0.17.21",
|
||||
"nodemon": "^3.0.1",
|
||||
"sealious": "^0.17.33",
|
||||
"stimulus": "^2.0.0",
|
||||
"tempstream": "^0.0.21"
|
||||
},
|
||||
@ -39,7 +51,8 @@
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.2",
|
||||
"@sealcode/ansi-html-stream": "^1.0.1",
|
||||
"@types/koa__router": "^8.0.4",
|
||||
"@types/mocha": "^9.1.0",
|
||||
"@types/mocha": "^9.1.1",
|
||||
"@types/node": "^20.8.4",
|
||||
"@types/tedious": "^4.0.7",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
"@typescript-eslint/parser": "^5.10.2",
|
||||
|
@ -2,6 +2,7 @@ import _locreq from "locreq";
|
||||
import { default as Sealious, App, LoggerMailer, SMTPMailer } from "sealious";
|
||||
import { LoggerLevel } from "sealious/@types/src/app/logger";
|
||||
import { collections } from "./collections/collections";
|
||||
import ADMIN_CREDENTIALS from "./default-admin-credentials";
|
||||
const locreq = _locreq(__dirname);
|
||||
|
||||
const PORT = process.env.SEALIOUS_PORT ? parseInt(process.env.SEALIOUS_PORT) : 8080;
|
||||
@ -47,7 +48,7 @@ export default class TheApp extends App {
|
||||
version: "0.0.1",
|
||||
default_language: "en",
|
||||
base_url,
|
||||
admin_email: "admin@example.com",
|
||||
admin_email: ADMIN_CREDENTIALS.email,
|
||||
colors: {
|
||||
primary: "#5294a1",
|
||||
},
|
||||
|
@ -5,6 +5,7 @@ import _GroupsToUsers from "./groups-to-users";
|
||||
import _Groups from "./groups";
|
||||
import _PasswordResetIntents from "./password-reset-intents";
|
||||
import _Secrets from "./secrets";
|
||||
import _Tasks from "./tasks";
|
||||
import _UserRoles from "./user-roles";
|
||||
import _Users from "./users";
|
||||
|
||||
@ -12,6 +13,7 @@ export const GroupsToUsers = new _GroupsToUsers();
|
||||
export const Groups = new _Groups();
|
||||
export const PasswordResetIntents = new _PasswordResetIntents();
|
||||
export const Secrets = new _Secrets();
|
||||
export const Tasks = new _Tasks();
|
||||
export const UserRoles = new _UserRoles();
|
||||
export const Users = new _Users();
|
||||
|
||||
@ -21,6 +23,7 @@ export const collections = {
|
||||
groups: Groups,
|
||||
"password-reset-intents": PasswordResetIntents,
|
||||
secrets: Secrets,
|
||||
tasks: Tasks,
|
||||
"user-roles": UserRoles,
|
||||
users: Users,
|
||||
};
|
||||
|
@ -3,7 +3,8 @@ import assert from "assert";
|
||||
import TheApp from "../app";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
describe("password-reset-intents", () => {
|
||||
describe("password-reset-intents", function () {
|
||||
//ts-ignore
|
||||
async function createAUser(app: TheApp) {
|
||||
await app.collections.users.suCreate({
|
||||
username: "user",
|
||||
@ -13,8 +14,8 @@ describe("password-reset-intents", () => {
|
||||
});
|
||||
}
|
||||
|
||||
it("tells you if the email address doesn't exist", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
it("tells you if the email address doesn't exist", async function () {
|
||||
return withProdApp(async ({ app, base_url }) => {
|
||||
const email = "fake@example.com";
|
||||
try {
|
||||
await axios.post(
|
||||
@ -31,7 +32,8 @@ describe("password-reset-intents", () => {
|
||||
return;
|
||||
}
|
||||
throw new Error("it didn't throw");
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("allows anyone to create an intent, if the email exists", async () =>
|
||||
withProdApp(async ({ app, base_url }) => {
|
||||
|
21
src/back/collections/tasks.ts
Normal file
21
src/back/collections/tasks.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { Collection, FieldTypes, Policies } from "sealious";
|
||||
|
||||
export default class Tasks extends Collection {
|
||||
fields = {
|
||||
title: new FieldTypes.Text(),
|
||||
done: new (class extends FieldTypes.Boolean {
|
||||
hasDefaultValue = () => true;
|
||||
async getDefaultValue() {
|
||||
return false;
|
||||
}
|
||||
})(),
|
||||
};
|
||||
|
||||
policies = {
|
||||
create: new Policies.Public(),
|
||||
show: new Policies.Owner(),
|
||||
list: new Policies.Owner(),
|
||||
};
|
||||
|
||||
defaultPolicy = new Policies.Public();
|
||||
}
|
@ -1,11 +1,13 @@
|
||||
import { App, Collections, Context, FieldTypes, Policies } from "sealious";
|
||||
import assert from "assert";
|
||||
import TheApp from "../app";
|
||||
import ADMIN_CREDENTIALS from "../default-admin-credentials";
|
||||
|
||||
export default class Users extends Collections.users {
|
||||
fields = {
|
||||
...App.BaseCollections.users.fields,
|
||||
email: new FieldTypes.Email().setRequired(true),
|
||||
username: new FieldTypes.Username().setRequired(true),
|
||||
roles: new FieldTypes.ReverseSingleReference({
|
||||
referencing_collection: "user-roles",
|
||||
referencing_field: "user",
|
||||
@ -18,7 +20,7 @@ export default class Users extends Collections.users {
|
||||
assert(app instanceof TheApp);
|
||||
await super.init(app, name);
|
||||
app.on("started", async () => {
|
||||
const username = "admin";
|
||||
const username = ADMIN_CREDENTIALS.username;
|
||||
const users = await app.collections.users
|
||||
.suList()
|
||||
.filter({ username })
|
||||
@ -30,8 +32,8 @@ export default class Users extends Collections.users {
|
||||
);
|
||||
await app.collections.users.suCreate({
|
||||
username,
|
||||
password: "adminadmin",
|
||||
email: "admin@example.com",
|
||||
password: ADMIN_CREDENTIALS.password,
|
||||
email: ADMIN_CREDENTIALS.email,
|
||||
roles: [],
|
||||
});
|
||||
}
|
||||
|
7
src/back/default-admin-credentials.ts
Normal file
7
src/back/default-admin-credentials.ts
Normal file
@ -0,0 +1,7 @@
|
||||
const ADMIN_CREDENTIALS = {
|
||||
username: "admin",
|
||||
password: "adminadmin",
|
||||
email: "admin@example.com",
|
||||
};
|
||||
|
||||
export default ADMIN_CREDENTIALS;
|
@ -1,4 +1,4 @@
|
||||
import { App, EmailTemplates, Errors } from "sealious";
|
||||
import { EmailTemplates, Errors } from "sealious";
|
||||
import TheApp from "../app";
|
||||
|
||||
export default async function PasswordResetTemplate(
|
||||
@ -18,7 +18,7 @@ export default async function PasswordResetTemplate(
|
||||
|
||||
return EmailTemplates.Simple(app, {
|
||||
subject: app.i18n("password_reset_email_subject", [app.manifest.name]),
|
||||
to: `${username}<${email_address}>`,
|
||||
to: `${String(username)}<${email_address}>`,
|
||||
text: `
|
||||
${app.i18n("password_reset_email_text", [app.manifest.name, username])}`,
|
||||
buttons: [
|
||||
|
@ -10,7 +10,7 @@ kill(app.config["www-server"].port)
|
||||
.then(() => app.start())
|
||||
.then(async () => {
|
||||
if (process.env.SEALIOUS_SANITY === "true") {
|
||||
console.log("Exiting with error code 0");
|
||||
console.error("Exiting with error code 0");
|
||||
process.exit(0);
|
||||
}
|
||||
mainRouter(app.HTTPServer.router);
|
||||
@ -18,7 +18,7 @@ kill(app.config["www-server"].port)
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
if (process.env.SEALIOUS_SANITY === "true") {
|
||||
console.log("EXITING WITH STATUS 1");
|
||||
console.error("EXITING WITH STATUS 1");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { BaseContext } from "koa";
|
||||
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
|
||||
import { Collection, Errors } from "sealious";
|
||||
import { ItemFields } from "sealious/@types/src/chip-types/collection-item-body";
|
||||
import ItemFields from "sealious/@types/src/chip-types/collection-item-body";
|
||||
|
||||
export interface CollectionTiedFormData<C extends Collection> {
|
||||
values: Partial<{ [field in keyof ItemFields<C>]: string }>;
|
||||
|
@ -1,6 +1,23 @@
|
||||
import { BaseContext } from "koa";
|
||||
import { SignUpURL, SignInURL, TodoURL, LogoutURL } from "../urls";
|
||||
|
||||
export default async function navbar(ctx: BaseContext) {
|
||||
const isLoggedIn = !!ctx.$context.session_id;
|
||||
|
||||
const linkData = isLoggedIn
|
||||
? [
|
||||
{ text: "Logout", url: LogoutURL },
|
||||
{ text: "To do app", url: TodoURL },
|
||||
]
|
||||
: [
|
||||
{ text: "Sign in", url: SignInURL },
|
||||
{ text: "Sign up", url: SignUpURL },
|
||||
];
|
||||
|
||||
const linksHTML = linkData
|
||||
.map((link) => `<li><a href="${link.url}">${link.text}</a></li>`)
|
||||
.join("\n");
|
||||
|
||||
return /* HTML */ ` <nav>
|
||||
<a href="/" class="nav-logo">
|
||||
<img
|
||||
@ -12,7 +29,7 @@ export default async function navbar(ctx: BaseContext) {
|
||||
Sealious App
|
||||
</a>
|
||||
<ul>
|
||||
<li><a href="/logowanie">Logowanie</a></li>
|
||||
${linksHTML}
|
||||
</ul>
|
||||
</nav>`;
|
||||
}
|
||||
|
53
src/back/routes/common/tasks-view.ts
Normal file
53
src/back/routes/common/tasks-view.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { BaseContext } from "koa";
|
||||
import { CollectionItem } from "sealious";
|
||||
import frame from "../../frame";
|
||||
import { Tasks } from "../../collections/collections";
|
||||
|
||||
export function Task(task: CollectionItem<typeof Tasks>) {
|
||||
return frame(
|
||||
`task-${task.id}`,
|
||||
/* HTML */ `<li class="task">
|
||||
<input
|
||||
type="checkbox"
|
||||
data-controller="task"
|
||||
data-action="task#toggle"
|
||||
data-id="${task.id}"
|
||||
${task.get("done") ? "checked" : ""}
|
||||
/>
|
||||
${task.get("title") as string}
|
||||
<form method="POST" action="/todo/">
|
||||
<input class="delete-button" type="submit" value="Delete" />
|
||||
<input
|
||||
class="hidden-button"
|
||||
type="hidden"
|
||||
name="taskId"
|
||||
value="${task.id}"
|
||||
/>
|
||||
<input
|
||||
class="hidden-button"
|
||||
type="hidden"
|
||||
id="action"
|
||||
name="action"
|
||||
value="delete"
|
||||
/>
|
||||
</form>
|
||||
</li>`
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
<form method="DELETE" action="/todo/${task.id}">
|
||||
<input class="delete-button" type="submit" value="Delete" />
|
||||
</form>
|
||||
*/
|
||||
|
||||
export async function TaskList(ctx: BaseContext) {
|
||||
const { items: tasks } = await ctx.$app.collections.tasks.list(ctx.$context).fetch();
|
||||
|
||||
const tasksTemplate = tasks.map(Task).join("\n");
|
||||
return `
|
||||
<ul>
|
||||
${tasksTemplate}
|
||||
</ul>
|
||||
`;
|
||||
}
|
@ -1,10 +1,10 @@
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
import { LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||
import { HelloURL } from "./urls";
|
||||
|
||||
describe("Hello", () => {
|
||||
it("doesn't crash", async function () {
|
||||
this.timeout(LONG_TEST_TIMEOUT);
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
return withProdApp(async ({ base_url, rest_api }) => {
|
||||
await rest_api.get(HelloURL);
|
||||
await webhintURL(base_url + HelloURL);
|
||||
|
@ -1,8 +1,8 @@
|
||||
import { webhintURL } from "../test_utils/webhint";
|
||||
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
|
||||
describe("homepage", function () {
|
||||
this.timeout(200000);
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
it("passes webhint tests", () =>
|
||||
withProdApp(async ({ base_url }) => {
|
||||
await webhintURL(`${base_url}/`);
|
||||
|
30
src/back/routes/logout.redirect.ts
Normal file
30
src/back/routes/logout.redirect.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Context } from "koa";
|
||||
import { Mountable } from "@sealcode/sealgen";
|
||||
import Router from "@koa/router";
|
||||
|
||||
export const actionName = "Logout";
|
||||
|
||||
export default new (class LogoutRedirect extends Mountable {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async canAccess(_: Context) {
|
||||
return { canAccess: true, message: "" };
|
||||
}
|
||||
|
||||
mount(router: Router, path: string) {
|
||||
router.get(path, async (ctx) => {
|
||||
try {
|
||||
const session_id: string = ctx.cookies.get("sealious-session") as string;
|
||||
if (session_id) {
|
||||
await ctx.$app.collections.sessions.logout(
|
||||
new ctx.$app.SuperContext(),
|
||||
session_id
|
||||
);
|
||||
ctx.status = 302;
|
||||
ctx.redirect("/");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error during logout:", error);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
55
src/back/routes/logout.test.ts
Normal file
55
src/back/routes/logout.test.ts
Normal file
@ -0,0 +1,55 @@
|
||||
import assert from "assert";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
import { LONG_TEST_TIMEOUT, VERY_LONG_TEST_TIMEOUT } from "../test_utils/webhint";
|
||||
import { LogoutURL, SignInURL } from "./urls";
|
||||
import { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { getBrowser } from "../test_utils/browser-creator";
|
||||
import ADMIN_CREDENTIALS from "../default-admin-credentials";
|
||||
|
||||
describe("Logout", () => {
|
||||
let page: Page;
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
const username = ADMIN_CREDENTIALS.username;
|
||||
const password = ADMIN_CREDENTIALS.password;
|
||||
|
||||
beforeEach(async () => {
|
||||
browser = await getBrowser();
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("doesn't crash", async function () {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
return withProdApp(async ({ rest_api }) => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await rest_api.get(LogoutURL);
|
||||
},
|
||||
{ name: "Error" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("logout test", () => {
|
||||
it("logout", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill(password);
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(`a[href="${LogoutURL}"]`);
|
||||
await page.getByRole("link", { name: "Logout" }).click();
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -5,7 +5,15 @@ import { mount } from "@sealcode/sealgen";
|
||||
import * as URLs from "./urls";
|
||||
|
||||
import { default as Hello } from "./hello.page";
|
||||
import { default as Logout } from "./logout.redirect";
|
||||
import { default as SignIn } from "./signIn.form";
|
||||
import { default as SignUp } from "./signUp.form";
|
||||
import { default as Todo } from "./todo.form";
|
||||
|
||||
export default function mountAutoRoutes(router: Router) {
|
||||
mount(router, URLs.HelloURL, Hello);
|
||||
mount(router, URLs.LogoutURL, Logout);
|
||||
mount(router, URLs.SignInURL, SignIn);
|
||||
mount(router, URLs.SignUpURL, SignUp);
|
||||
mount(router, URLs.TodoURL, Todo);
|
||||
}
|
||||
|
121
src/back/routes/signIn.form.ts
Normal file
121
src/back/routes/signIn.form.ts
Normal file
@ -0,0 +1,121 @@
|
||||
import { Context } from "koa";
|
||||
import {
|
||||
Form,
|
||||
FormData,
|
||||
FormDataValue,
|
||||
Fields,
|
||||
Controls,
|
||||
FormReaction,
|
||||
} from "@sealcode/sealgen";
|
||||
import html from "../html";
|
||||
import { Users } from "../collections/collections";
|
||||
import { FlatTemplatable, tempstream } from "tempstream";
|
||||
import { PageErrorMessage } from "@sealcode/sealgen/@types/page/mountable-with-fields";
|
||||
|
||||
export const actionName = "SignIn";
|
||||
|
||||
const fields = {
|
||||
username: new Fields.SimpleFormField(true),
|
||||
password: new Fields.SimpleFormField(true),
|
||||
};
|
||||
|
||||
export const SignInShape = Fields.fieldsToShape(fields);
|
||||
|
||||
export default new (class SignInForm extends Form<typeof fields, void> {
|
||||
defaultSuccessMessage = "Formularz wypełniony poprawnie";
|
||||
fields = fields;
|
||||
|
||||
controls = [
|
||||
new Controls.SimpleInput(fields.username, { label: "Username:", type: "text" }),
|
||||
new Controls.SimpleInput(fields.password, {
|
||||
label: "Password:",
|
||||
type: "password",
|
||||
}),
|
||||
];
|
||||
|
||||
async validateValues(
|
||||
ctx: Context,
|
||||
data: Record<string, FormDataValue>
|
||||
): Promise<{ valid: boolean; error: string }> {
|
||||
const { parsed: username } = await this.fields.username.getValue(ctx, data);
|
||||
|
||||
const filter: object = typeof username === "string" ? { username } : {};
|
||||
|
||||
const user = await Users.suList().filter(filter).fetch();
|
||||
|
||||
if (user.empty) {
|
||||
return { valid: false, error: `Incorrect password or username` };
|
||||
}
|
||||
|
||||
return { valid: true, error: `` };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async canAccess(ctx: Context) {
|
||||
if (ctx.$context.session_id) {
|
||||
return { canAccess: false, message: "" };
|
||||
}
|
||||
return { canAccess: true, message: "" };
|
||||
}
|
||||
|
||||
async onSuccess(
|
||||
_: Context,
|
||||
__: FormData<string>,
|
||||
_submitResult: void
|
||||
): Promise<FormReaction> {
|
||||
const reaction: FormReaction = {
|
||||
action: "redirect",
|
||||
url: "/",
|
||||
};
|
||||
console.log("Successfully logged in.");
|
||||
return reaction;
|
||||
}
|
||||
|
||||
async onError(
|
||||
ctx: Context,
|
||||
data: FormData<string>,
|
||||
error: unknown
|
||||
): Promise<FormReaction> {
|
||||
const reaction: FormReaction = {
|
||||
action: "stay",
|
||||
content: this.render(ctx, data, true),
|
||||
messages: [
|
||||
{
|
||||
type: "error",
|
||||
text: `There was an error while logging in: ${String(error)}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
return reaction;
|
||||
}
|
||||
|
||||
async onSubmit(ctx: Context, data: FormData) {
|
||||
try {
|
||||
const sessionId: string = await Users.app.collections.sessions.login(
|
||||
data.raw_values.username as string,
|
||||
data.raw_values.password as string
|
||||
);
|
||||
|
||||
ctx.cookies.set("sealious-session", sessionId, {
|
||||
maxAge: 1000 * 60 * 60 * 24 * 7,
|
||||
secure: ctx.request.protocol === "https",
|
||||
overwrite: true,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async renderError(ctx: Context, error: PageErrorMessage): Promise<FlatTemplatable> {
|
||||
return html(ctx, "SignIn", `${error.message}`);
|
||||
}
|
||||
|
||||
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
|
||||
return html(
|
||||
ctx,
|
||||
"SignIn",
|
||||
tempstream`${await super.render(ctx, data, show_field_errors)}`
|
||||
);
|
||||
}
|
||||
})();
|
103
src/back/routes/signIn.test.ts
Normal file
103
src/back/routes/signIn.test.ts
Normal file
@ -0,0 +1,103 @@
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||
import { SignInURL, LogoutURL } from "./urls";
|
||||
import { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { getBrowser } from "../test_utils/browser-creator";
|
||||
import ADMIN_CREDENTIALS from "../default-admin-credentials";
|
||||
|
||||
describe("SignIn", () => {
|
||||
let page: Page;
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
const username = ADMIN_CREDENTIALS.username;
|
||||
const password = ADMIN_CREDENTIALS.password;
|
||||
|
||||
beforeEach(async () => {
|
||||
browser = await getBrowser();
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("doesn't crash", async function () {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
return withProdApp(async ({ base_url, rest_api }) => {
|
||||
await rest_api.get(SignInURL);
|
||||
await webhintURL(base_url + SignInURL);
|
||||
// alternatively you can use webhintHTML for faster but less precise scans
|
||||
// or for scanning responses of requests that use some form of authorization:
|
||||
// const response = await rest_api.get(SignInURL);
|
||||
// await webhintHTML(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("can access test", () => {
|
||||
it("access url", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill(password);
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(`a[href="${LogoutURL}"]`);
|
||||
await page.goto(base_url + SignInURL);
|
||||
await page.waitForSelector('body:has-text("no access")');
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Logout" }).click();
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sign in test", () => {
|
||||
it("wrong username", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill("username20230720722");
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill("test");
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(".form-message");
|
||||
});
|
||||
});
|
||||
|
||||
it("correct username and password", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill(password);
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(`a[href="${LogoutURL}"]`);
|
||||
await page.getByRole("link", { name: "Logout" }).click();
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
});
|
||||
});
|
||||
|
||||
it("wrong password", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill("asddasads20230720722");
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(".form-message");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
139
src/back/routes/signUp.form.ts
Normal file
139
src/back/routes/signUp.form.ts
Normal file
@ -0,0 +1,139 @@
|
||||
import { Context } from "koa";
|
||||
import {
|
||||
Form,
|
||||
FormData,
|
||||
FormDataValue,
|
||||
Fields,
|
||||
Controls,
|
||||
FormReaction,
|
||||
} from "@sealcode/sealgen";
|
||||
import html from "../html";
|
||||
import { Users } from "../collections/collections";
|
||||
|
||||
export const actionName = "SignUp";
|
||||
|
||||
const fields = {
|
||||
username: new Fields.CollectionField(true, Users.fields.username),
|
||||
email: new Fields.CollectionField(true, Users.fields.email),
|
||||
password: new Fields.SimpleFormField(true),
|
||||
};
|
||||
|
||||
export const SignUpShape = Fields.fieldsToShape(fields);
|
||||
|
||||
export default new (class SignUpForm extends Form<typeof fields, void> {
|
||||
defaultSuccessMessage = "Formularz wypełniony poprawnie";
|
||||
fields = fields;
|
||||
|
||||
controls = [
|
||||
new Controls.SimpleInput(fields.username, { label: "Username:", type: "text" }),
|
||||
new Controls.SimpleInput(fields.email, { label: "Email:", type: "email" }),
|
||||
new Controls.SimpleInput(fields.password, {
|
||||
label: "Password:",
|
||||
type: "password",
|
||||
}),
|
||||
];
|
||||
|
||||
async validateValues(
|
||||
ctx: Context,
|
||||
data: Record<string, FormDataValue>
|
||||
): Promise<{ valid: boolean; error: string }> {
|
||||
const { parsed: email } = await this.fields.email.getValue(ctx, data);
|
||||
const { parsed: password } = await this.fields.password.getValue(ctx, data);
|
||||
|
||||
if ((password || "").length >= 8) {
|
||||
const user = await Users.suList().filter({ email: email }).fetch();
|
||||
if (user.empty) {
|
||||
return { valid: true, error: `` };
|
||||
}
|
||||
return { valid: false, error: `Email is arleady taken` };
|
||||
} else {
|
||||
return {
|
||||
valid: false,
|
||||
error: "Password must contain a minimum of 8 characters",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async canAccess(ctx: Context) {
|
||||
if (ctx.$context.session_id) {
|
||||
return { canAccess: false, message: "" };
|
||||
}
|
||||
return { canAccess: true, message: "" };
|
||||
}
|
||||
|
||||
async onError(
|
||||
ctx: Context,
|
||||
data: FormData<string>,
|
||||
error: unknown
|
||||
): Promise<FormReaction> {
|
||||
const reaction: FormReaction = {
|
||||
action: "stay",
|
||||
content: this.render(ctx, data, true),
|
||||
messages: [
|
||||
{
|
||||
type: "error",
|
||||
text: `An unexpected error occurred, try again. <br> Error${
|
||||
error as string
|
||||
}`,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return reaction;
|
||||
}
|
||||
|
||||
async onSuccess(ctx: Context, data: FormData): Promise<FormReaction> {
|
||||
const username: FormDataValue = data.raw_values.username;
|
||||
const reaction: FormReaction = {
|
||||
action: "stay",
|
||||
content: `Hello ${String(
|
||||
username
|
||||
)}. <p class="success-notify">Your account has been successfully created.</p>
|
||||
<a href="/" class="nav-logo">
|
||||
<img
|
||||
src="/assets/logo"
|
||||
alt="${ctx.$app.manifest.name} - logo"
|
||||
width="50"
|
||||
height="50"
|
||||
/>
|
||||
Sealious App
|
||||
</a>`,
|
||||
messages: [
|
||||
{
|
||||
type: "success",
|
||||
text: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return reaction;
|
||||
}
|
||||
|
||||
async onSubmit(ctx: Context, data: FormData) {
|
||||
const username: string =
|
||||
typeof data.raw_values.username === "string" ? data.raw_values.username : "";
|
||||
const password: string =
|
||||
typeof data.raw_values.password === "string" ? data.raw_values.password : "";
|
||||
const email: string =
|
||||
typeof data.raw_values.email === "string" ? data.raw_values.email : "";
|
||||
|
||||
try {
|
||||
await Users.suCreate({
|
||||
username: username,
|
||||
password: password,
|
||||
email: email,
|
||||
roles: [],
|
||||
});
|
||||
console.log("A user was created successfully.");
|
||||
} catch (error) {
|
||||
console.error("Error during user creation:", error);
|
||||
throw new Error(String(error));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
|
||||
return html(ctx, "SignUp", await super.render(ctx, data, show_field_errors));
|
||||
}
|
||||
})();
|
122
src/back/routes/signUp.test.ts
Normal file
122
src/back/routes/signUp.test.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
import { VERY_LONG_TEST_TIMEOUT, webhintURL } from "../test_utils/webhint";
|
||||
import { SignUpURL, LogoutURL, SignInURL } from "./urls";
|
||||
import { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { getBrowser } from "../test_utils/browser-creator";
|
||||
import ADMIN_CREDENTIALS from "../default-admin-credentials";
|
||||
|
||||
describe("SignUp", () => {
|
||||
let page: Page;
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
const username = ADMIN_CREDENTIALS.username;
|
||||
const password = ADMIN_CREDENTIALS.password;
|
||||
const email = ADMIN_CREDENTIALS.email;
|
||||
|
||||
beforeEach(async () => {
|
||||
browser = await getBrowser();
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("doesn't crash", async function () {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
return withProdApp(async ({ base_url, rest_api }) => {
|
||||
await rest_api.get(SignUpURL);
|
||||
await webhintURL(base_url + SignUpURL);
|
||||
// alternatively you can use webhintHTML for faster but less precise scans
|
||||
// or for scanning responses of requests that use some form of authorization:
|
||||
// const response = await rest_api.get(SignUpURL);
|
||||
// await webhintHTML(response);
|
||||
});
|
||||
});
|
||||
|
||||
describe("signup test", () => {
|
||||
it("username is taken", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign up" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("email").fill("user0192939@randomsuper.com");
|
||||
await page.getByPlaceholder("email").press("Tab");
|
||||
await page.getByPlaceholder("password").fill("user12341234");
|
||||
await page.getByRole("button", { name: "Wyślij" }).click();
|
||||
await page.waitForSelector(".input__error");
|
||||
});
|
||||
});
|
||||
it("password is too shot ", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign up" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill("dasdsa");
|
||||
await page.getByPlaceholder("email").click();
|
||||
await page
|
||||
.getByPlaceholder("email")
|
||||
.fill("asasdsdadsadss123asddsa@asdasca.com");
|
||||
await page.getByPlaceholder("password").click();
|
||||
await page.getByPlaceholder("password").fill("asddsa");
|
||||
await page.getByRole("button", { name: "Wyślij" }).click();
|
||||
await page.waitForSelector(".form-message.form-message--error");
|
||||
});
|
||||
});
|
||||
it("email is taken", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign up" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill("ranomusername2023072722");
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("email").fill(email);
|
||||
await page.getByPlaceholder("email").press("Tab");
|
||||
await page.getByPlaceholder("password").fill("asdasdasdasdasd");
|
||||
await page.getByRole("button", { name: "Wyślij" }).click();
|
||||
await page.waitForSelector(".form-message.form-message--error");
|
||||
});
|
||||
});
|
||||
it("correct", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign up" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill("ranomusername20230720722");
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("email").fill("radomemail@emailrandom.com");
|
||||
await page.getByPlaceholder("email").press("Tab");
|
||||
await page.getByPlaceholder("password").fill("asdasdasdasdasd");
|
||||
await page.getByRole("button", { name: "Wyślij" }).click();
|
||||
await page.waitForSelector(".success-notify");
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("can access test", () => {
|
||||
it("access url", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill(password);
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.waitForSelector(`a[href="${LogoutURL}"]`);
|
||||
await page.goto(base_url + SignUpURL);
|
||||
await page.waitForSelector('body:has-text("no access")');
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Logout" }).click();
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
107
src/back/routes/todo.form.ts
Normal file
107
src/back/routes/todo.form.ts
Normal file
@ -0,0 +1,107 @@
|
||||
import { Tasks } from "./../collections/collections";
|
||||
import { tempstream } from "tempstream";
|
||||
import { Context } from "koa";
|
||||
import { Form, FormData, FormDataValue, Fields, Controls } from "@sealcode/sealgen";
|
||||
import html from "../html";
|
||||
import { TaskList } from "./common/tasks-view";
|
||||
|
||||
export const actionName = "Todo";
|
||||
|
||||
const fields = {
|
||||
name: new Fields.CollectionField(true, Tasks.fields.title),
|
||||
};
|
||||
|
||||
export const TodoShape = Fields.fieldsToShape(fields);
|
||||
|
||||
export default new (class TodoForm extends Form<typeof fields, void> {
|
||||
defaultSuccessMessage = "Task has been successfully created";
|
||||
fields = fields;
|
||||
|
||||
controls = [
|
||||
new Controls.SimpleInput(fields.name, {
|
||||
label: "Task name:",
|
||||
type: "text",
|
||||
placeholder: "Write an Matrix bot",
|
||||
}),
|
||||
new Controls.HTML("decoration", (fctx) => {
|
||||
return `<input class="hidden-button" type="hidden" id="action" name="action" value="create" form="${fctx.form_id}" />`;
|
||||
}),
|
||||
];
|
||||
|
||||
async validateValues(
|
||||
ctx: Context,
|
||||
data: Record<string, FormDataValue>
|
||||
): Promise<{ valid: boolean; error: string }> {
|
||||
const { parsed: name } = await this.fields.name.getValue(ctx, data);
|
||||
|
||||
if ((name || "").length < 3) {
|
||||
return {
|
||||
valid: true,
|
||||
error: "The name of the task must have at least 3 characters",
|
||||
};
|
||||
} else {
|
||||
const filter: object = name ? { title: name } : {};
|
||||
|
||||
const tasks = await ctx.$app.collections.tasks
|
||||
.list(ctx.$context)
|
||||
.filter(filter)
|
||||
.fetch();
|
||||
if (tasks.empty) {
|
||||
return { valid: true, error: "" };
|
||||
}
|
||||
return { valid: false, error: "Task with the same name already exists" };
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
async canAccess(ctx: Context) {
|
||||
if (ctx.$context.session_id) {
|
||||
return { canAccess: true, message: "" };
|
||||
}
|
||||
return { canAccess: false, message: "" };
|
||||
}
|
||||
|
||||
async onSubmit(ctx: Context, data: FormData) {
|
||||
const action: FormDataValue = data.raw_values.action;
|
||||
|
||||
switch (action) {
|
||||
case "create": {
|
||||
try {
|
||||
await ctx.$app.collections.tasks.create(ctx.$context, {
|
||||
title: String(data.raw_values.name),
|
||||
done: false,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error();
|
||||
}
|
||||
console.debug(`task has been successfully created`);
|
||||
break;
|
||||
}
|
||||
case "delete": {
|
||||
const task = await ctx.$app.collections.tasks.getByID(
|
||||
ctx.$context,
|
||||
data.raw_values.taskId as string
|
||||
);
|
||||
await task.remove(ctx.$context);
|
||||
console.debug(`task has been successfully removed`);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
console.debug("Wrong action");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async render(ctx: Context, data: FormData, show_field_errors: boolean) {
|
||||
return html(
|
||||
ctx,
|
||||
"Todo",
|
||||
tempstream`${await super.render(ctx, data, show_field_errors)}
|
||||
${TaskList(ctx)}
|
||||
`
|
||||
);
|
||||
}
|
||||
})();
|
81
src/back/routes/todo.test.ts
Normal file
81
src/back/routes/todo.test.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import assert from "assert";
|
||||
import { withProdApp } from "../test_utils/with-prod-app";
|
||||
import { LONG_TEST_TIMEOUT, VERY_LONG_TEST_TIMEOUT } from "../test_utils/webhint";
|
||||
import { SignInURL, TodoURL } from "./urls";
|
||||
import { Browser, BrowserContext, Page } from "@playwright/test";
|
||||
import { getBrowser } from "../test_utils/browser-creator";
|
||||
import ADMIN_CREDENTIALS from "../default-admin-credentials";
|
||||
|
||||
describe("Todo", function () {
|
||||
let page: Page;
|
||||
let browser: Browser;
|
||||
let context: BrowserContext;
|
||||
const username = ADMIN_CREDENTIALS.username;
|
||||
const password = ADMIN_CREDENTIALS.password;
|
||||
|
||||
beforeEach(async () => {
|
||||
browser = await getBrowser();
|
||||
context = await browser.newContext();
|
||||
page = await context.newPage();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await context.close();
|
||||
});
|
||||
|
||||
it("doesn't crash", async function () {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
return withProdApp(async ({ rest_api }) => {
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await rest_api.get(TodoURL);
|
||||
},
|
||||
{ name: "Error" }
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("todo test", () => {
|
||||
it("create and delete task", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(VERY_LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
await page.getByRole("link", { name: "Sign in" }).click();
|
||||
await page.getByPlaceholder("text").click();
|
||||
await page.getByPlaceholder("text").fill(username);
|
||||
await page.getByPlaceholder("text").press("Tab");
|
||||
await page.getByPlaceholder("password").fill(password);
|
||||
await page.getByPlaceholder("password").press("Enter");
|
||||
await page.getByRole("link", { name: "To do app" }).click();
|
||||
await page.getByPlaceholder("Write an Matrix bot").click();
|
||||
await page.getByPlaceholder("Write an Matrix bot").fill("randomtasdk");
|
||||
await page.getByRole("button", { name: "Wyślij" }).click();
|
||||
await page.waitForSelector(".form-message.form-message--success");
|
||||
await page.locator("turbo-frame").getByRole("checkbox").check();
|
||||
await page.locator("turbo-frame").getByRole("checkbox").uncheck();
|
||||
await page
|
||||
.locator("turbo-frame")
|
||||
.getByRole("button", { name: "Delete" })
|
||||
.click();
|
||||
await page.getByRole("link", { name: "Logout" }).click();
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("can access test", () => {
|
||||
it("access url", async function () {
|
||||
await withProdApp(async ({ base_url }) => {
|
||||
this.timeout(LONG_TEST_TIMEOUT);
|
||||
await page.goto(base_url);
|
||||
try {
|
||||
await page.waitForSelector(`a[href="${SignInURL}"]`);
|
||||
await page.goto(base_url + TodoURL);
|
||||
await page.waitForSelector('body:has-text("no access")');
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
@ -1 +1,5 @@
|
||||
export const HelloURL = "/hello/";
|
||||
export const LogoutURL = "/logout/";
|
||||
export const SignInURL = "/signIn/";
|
||||
export const SignUpURL = "/signUp/";
|
||||
export const TodoURL = "/todo/";
|
||||
|
@ -1,5 +1,5 @@
|
||||
describe("sample test", () => {
|
||||
it("always passes", () => {
|
||||
return true;
|
||||
return;
|
||||
});
|
||||
});
|
||||
|
16
src/back/test_utils/browser-creator.ts
Normal file
16
src/back/test_utils/browser-creator.ts
Normal file
@ -0,0 +1,16 @@
|
||||
import { Browser, firefox } from "@playwright/test";
|
||||
|
||||
let browser: Browser;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||
export async function getBrowser(): Promise<Browser> {
|
||||
if (!browser) browser = await firefox.launch();
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return browser;
|
||||
}
|
||||
|
||||
export async function closeBrowser() {
|
||||
if (browser) {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
@ -4,7 +4,8 @@ import { spawn } from "child_process";
|
||||
import { hasShape, is, predicates } from "@sealcode/ts-predicates";
|
||||
import { promises as fs } from "fs";
|
||||
|
||||
export const LONG_TEST_TIMEOUT = 30 * 1000;
|
||||
export const LONG_TEST_TIMEOUT = 100 * 1000;
|
||||
export const VERY_LONG_TEST_TIMEOUT = 75 * 1000;
|
||||
|
||||
export async function webhintURL(url: string, config = locreq.resolve(".hintrc")) {
|
||||
// eslint-disable-next-line no-console
|
||||
|
@ -1,9 +1,10 @@
|
||||
import TheApp from "../app";
|
||||
import { mainRouter } from "../routes";
|
||||
import _locreq from "locreq";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
const locreq = _locreq(__dirname);
|
||||
import Sealious, { SMTPMailer } from "sealious";
|
||||
import { SMTPMailer } from "sealious";
|
||||
import { TestUtils } from "sealious";
|
||||
|
||||
export async function withProdApp(
|
||||
@ -21,7 +22,7 @@ export async function withProdApp(
|
||||
app.config.datastore_mongo = {
|
||||
host: "db",
|
||||
port: 27017,
|
||||
db_name: "sealious-app-test",
|
||||
db_name: "sealious-app-test" + uuid(),
|
||||
};
|
||||
app.config.logger.level = <const>"none";
|
||||
app.mailer = new SMTPMailer({
|
||||
@ -31,11 +32,11 @@ export async function withProdApp(
|
||||
password: "any",
|
||||
});
|
||||
|
||||
await app.start();
|
||||
mainRouter(app.HTTPServer.router);
|
||||
|
||||
app.HTTPServer.addStaticRoute("/", locreq.resolve("public"));
|
||||
|
||||
await app.start();
|
||||
const base_url = `http://127.0.0.1:${port}`;
|
||||
const mail_api = new TestUtils.MailcatcherAPI("http://mailcatcher:1080", app);
|
||||
await mail_api.deleteAllInstanceEmails();
|
||||
@ -52,9 +53,12 @@ export async function withProdApp(
|
||||
rest_api: new TestUtils.MockRestApi(base_url),
|
||||
mail_api,
|
||||
});
|
||||
|
||||
await stop();
|
||||
} catch (e) {
|
||||
if (app.status !== "stopped") {
|
||||
await stop();
|
||||
}
|
||||
console.error(e);
|
||||
throw e;
|
||||
}
|
||||
|
@ -10,7 +10,7 @@
|
||||
"lib": ["es6", "esnext"],
|
||||
"outDir": "../../dist/back",
|
||||
"keyofStringsOnly": true,
|
||||
"checkJs": true,
|
||||
"checkJs": false,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
"sourceMap": true,
|
||||
|
30
src/front/controllers/task-controller.ts
Normal file
30
src/front/controllers/task-controller.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Controller } from "stimulus";
|
||||
|
||||
export default class TaskController extends Controller {
|
||||
id: string;
|
||||
|
||||
connect() {
|
||||
const dataIdAttr = this.element.getAttribute("data-id");
|
||||
if (dataIdAttr) {
|
||||
this.id = dataIdAttr;
|
||||
}
|
||||
}
|
||||
|
||||
async toggle(event: Event) {
|
||||
const inputElement: HTMLInputElement = event.target as HTMLInputElement;
|
||||
|
||||
if (inputElement instanceof HTMLInputElement) {
|
||||
const isChecked: boolean = inputElement.checked;
|
||||
|
||||
await fetch(`/api/v1/collections/tasks/${this.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
done: isChecked,
|
||||
}),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
@ -1,6 +1,8 @@
|
||||
import * as Turbo from "@hotwired/turbo";
|
||||
import { Application } from "stimulus";
|
||||
import TaskController from "./controllers/task-controller";
|
||||
|
||||
export { Turbo };
|
||||
|
||||
const application = Application.start();
|
||||
application.register("task", TaskController);
|
||||
|
@ -2,7 +2,8 @@
|
||||
"compilerOptions": {
|
||||
"module": "CommonJS",
|
||||
"target": "ES6",
|
||||
"lib": ["dom"]
|
||||
"lib": ["dom"],
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["./**/*", "./index.ts"]
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user