Compare commits

...

9 commits

46 changed files with 2269 additions and 295 deletions

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 88

View file

@ -1,23 +1,13 @@
module.exports = {
root: true,
parser: "@typescript-eslint/parser",
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:svelte/recommended",
"prettier",
],
parser: "@typescript-eslint/parser",
plugins: ["@typescript-eslint"],
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
extraFileExtensions: [".svelte"],
},
env: {
browser: true,
es2017: true,
node: true,
},
plugins: ["@typescript-eslint", "no-relative-import-paths"],
ignorePatterns: ["*.cjs"],
overrides: [
{
files: ["*.svelte"],
@ -27,4 +17,22 @@ module.exports = {
},
},
],
settings: {},
parserOptions: {
sourceType: "module",
ecmaVersion: 2020,
},
env: {
browser: true,
es2017: true,
node: true,
},
rules: {
"no-relative-import-paths/no-relative-import-paths": [
"warn",
{ allowSameFolder: true },
],
"no-console": "warn",
"@typescript-eslint/no-unused-vars": ["warn", { args: "none" }],
},
};

View file

@ -7,24 +7,27 @@
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"test": "npm run test:integration && npm run test:unit",
"test": "vitest --run && vitest --config vitest.config.integration.ts --run",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .",
"format": "prettier --write .",
"test:integration": "playwright test",
"test:unit": "vitest"
"test:unit": "vitest",
"test:integration": "vitest --config vitest.config.integration.ts",
"test:e2e": "playwright test"
},
"dependencies": {
"@auth/core": "^0.18.4",
"@auth/prisma-adapter": "^1.0.9",
"@auth/sveltekit": "^0.3.15",
"@prisma/client": "^5.7.0"
"@auth/sveltekit": "^0.5.0",
"@mdi/js": "^7.3.67",
"@prisma/client": "^5.7.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@playwright/test": "^1.40.1",
"@sveltejs/adapter-auto": "^2.1.1",
"@sveltejs/kit": "^1.27.7",
"@sveltejs/adapter-node": "^2.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tailwindcss/typography": "^0.5.10",
"@types/node": "^20.10.5",
"@typescript-eslint/eslint-plugin": "^6.13.2",
@ -33,9 +36,10 @@
"daisyui": "^4.4.19",
"eslint": "^8.55.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-no-relative-import-paths": "^1.5.3",
"eslint-plugin-svelte": "^2.35.1",
"postcss": "^8.4.32",
"postcss-load-config": "^4.0.2",
"postcss-import": "^15.1.0",
"postcss-nesting": "^12.0.1",
"prettier": "^3.1.0",
"prettier-plugin-svelte": "^3.1.2",
"prisma": "^5.7.0",
@ -44,8 +48,8 @@
"tailwindcss": "^3.3.6",
"tslib": "^2.6.2",
"typescript": "^5.3.3",
"vite": "^4.5.1",
"vitest": "^0.32.4"
"vite": "^5.0.0",
"vitest": "^1.0.0"
},
"type": "module"
}

File diff suppressed because it is too large Load diff

View file

@ -1,13 +1,8 @@
const tailwindcss = require("tailwindcss");
const autoprefixer = require("autoprefixer");
const config = {
plugins: [
//Some plugins, like tailwindcss/nesting, need to run before Tailwind,
tailwindcss(),
//But others, like autoprefixer, need to run after,
autoprefixer,
],
module.exports = {
plugins: {
"tailwindcss/nesting": "postcss-nesting",
"postcss-import": {},
tailwindcss: {},
autoprefixer: {},
},
};
module.exports = config;

View file

@ -59,6 +59,8 @@ CREATE TABLE "patients" (
CREATE TABLE "categories" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"color" TEXT,
"description" TEXT,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
@ -90,6 +92,7 @@ CREATE TABLE "entry_versions" (
CREATE TABLE "entry_execution" (
"id" SERIAL NOT NULL,
"entryId" INTEGER NOT NULL,
"text" TEXT NOT NULL,
"authorId" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

View file

@ -85,6 +85,8 @@ model Patient {
model Category {
id Int @id @default(autoincrement())
name String
color String?
description String?
EntryVersion EntryVersion[]
@@map("categories")
@ -128,6 +130,8 @@ model EntryExecution {
entry Entry @relation(fields: [entryId], references: [id], onDelete: Cascade)
entryId Int
text String
author User @relation(fields: [authorId], references: [id])
authorId Int

1
run/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
postgres

12
run/db_up.sh Executable file
View file

@ -0,0 +1,12 @@
#!/bin/bash
set -e
DIR="$(cd "$(dirname "$0")" && pwd)"
docker-compose up -d
echo 'Waiting for database to be ready...'
"$DIR/wait-for-it.sh" "localhost:5432" -- echo 'Database is ready!'
# Create temporary test database
docker-compose exec -u 999:999 db sh -e -c 'dropdb -f --if-exists test; createdb test'
cd "$DIR/../" && DATABASE_URL="postgresql://postgres:1234@localhost:5432/test?schema=public" npx prisma migrate reset --force

182
run/wait-for-it.sh Executable file
View file

@ -0,0 +1,182 @@
#!/usr/bin/env bash
# Use this script to test if a given TCP host/port are available
WAITFORIT_cmdname=${0##*/}
echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi }
usage()
{
cat << USAGE >&2
Usage:
$WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args]
-h HOST | --host=HOST Host or IP under test
-p PORT | --port=PORT TCP port under test
Alternatively, you specify the host and port as host:port
-s | --strict Only execute subcommand if the test succeeds
-q | --quiet Don't output any status messages
-t TIMEOUT | --timeout=TIMEOUT
Timeout in seconds, zero for no timeout
-- COMMAND ARGS Execute command with args after the test finishes
USAGE
exit 1
}
wait_for()
{
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
else
echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout"
fi
WAITFORIT_start_ts=$(date +%s)
while :
do
if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then
nc -z $WAITFORIT_HOST $WAITFORIT_PORT
WAITFORIT_result=$?
else
(echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1
WAITFORIT_result=$?
fi
if [[ $WAITFORIT_result -eq 0 ]]; then
WAITFORIT_end_ts=$(date +%s)
echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds"
break
fi
sleep 1
done
return $WAITFORIT_result
}
wait_for_wrapper()
{
# In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692
if [[ $WAITFORIT_QUIET -eq 1 ]]; then
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
else
timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT &
fi
WAITFORIT_PID=$!
trap "kill -INT -$WAITFORIT_PID" INT
wait $WAITFORIT_PID
WAITFORIT_RESULT=$?
if [[ $WAITFORIT_RESULT -ne 0 ]]; then
echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT"
fi
return $WAITFORIT_RESULT
}
# process arguments
while [[ $# -gt 0 ]]
do
case "$1" in
*:* )
WAITFORIT_hostport=(${1//:/ })
WAITFORIT_HOST=${WAITFORIT_hostport[0]}
WAITFORIT_PORT=${WAITFORIT_hostport[1]}
shift 1
;;
--child)
WAITFORIT_CHILD=1
shift 1
;;
-q | --quiet)
WAITFORIT_QUIET=1
shift 1
;;
-s | --strict)
WAITFORIT_STRICT=1
shift 1
;;
-h)
WAITFORIT_HOST="$2"
if [[ $WAITFORIT_HOST == "" ]]; then break; fi
shift 2
;;
--host=*)
WAITFORIT_HOST="${1#*=}"
shift 1
;;
-p)
WAITFORIT_PORT="$2"
if [[ $WAITFORIT_PORT == "" ]]; then break; fi
shift 2
;;
--port=*)
WAITFORIT_PORT="${1#*=}"
shift 1
;;
-t)
WAITFORIT_TIMEOUT="$2"
if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi
shift 2
;;
--timeout=*)
WAITFORIT_TIMEOUT="${1#*=}"
shift 1
;;
--)
shift
WAITFORIT_CLI=("$@")
break
;;
--help)
usage
;;
*)
echoerr "Unknown argument: $1"
usage
;;
esac
done
if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then
echoerr "Error: you need to provide a host and port to test."
usage
fi
WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15}
WAITFORIT_STRICT=${WAITFORIT_STRICT:-0}
WAITFORIT_CHILD=${WAITFORIT_CHILD:-0}
WAITFORIT_QUIET=${WAITFORIT_QUIET:-0}
# Check to see if timeout is from busybox?
WAITFORIT_TIMEOUT_PATH=$(type -p timeout)
WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH)
WAITFORIT_BUSYTIMEFLAG=""
if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then
WAITFORIT_ISBUSY=1
# Check if busybox timeout uses -t flag
# (recent Alpine versions don't support -t anymore)
if timeout &>/dev/stdout | grep -q -e '-t '; then
WAITFORIT_BUSYTIMEFLAG="-t"
fi
else
WAITFORIT_ISBUSY=0
fi
if [[ $WAITFORIT_CHILD -gt 0 ]]; then
wait_for
WAITFORIT_RESULT=$?
exit $WAITFORIT_RESULT
else
if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then
wait_for_wrapper
WAITFORIT_RESULT=$?
else
wait_for
WAITFORIT_RESULT=$?
fi
fi
if [[ $WAITFORIT_CLI != "" ]]; then
if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then
echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess"
exit $WAITFORIT_RESULT
fi
exec "${WAITFORIT_CLI[@]}"
else
exit $WAITFORIT_RESULT
fi

View file

@ -1,5 +1,5 @@
import { prisma } from "$lib/prisma";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { prisma } from "$lib/server/prisma";
import { PrismaAdapter } from "$lib/server/authAdapter";
import { SvelteKitAuth } from "@auth/sveltekit";
import Keycloak from "@auth/core/providers/keycloak";
import {
@ -19,7 +19,7 @@ const authorization: Handle = async ({ event, resolve }) => {
const session = await event.locals.getSession();
if (!session) {
const params = new URLSearchParams({ returnURL: event.url.pathname });
throw redirect(303, "/login?" + params.toString());
redirect(303, "/login?" + params.toString());
}
}

View file

@ -0,0 +1,59 @@
<script lang="ts">
export let path: string;
export let size = 1.5;
export let color = "currentColor";
export let flipV = false;
export let flipH = false;
export let rotate = 0;
export let spin = 0;
export let title = "";
export let cls = "";
$: scaleV = flipV ? -1 : 1;
$: scaleH = flipH ? -1 : 1;
$: absSpin = Math.abs(spin);
</script>
<svg
viewBox="0 0 24 24"
style="--color: {color}; --size: {size}rem; --rotate: {rotate}deg; --scaleV: {scaleV}; --scaleH:
{scaleH}; --spin-duration: {absSpin}s;"
class={cls}
>
{#if title}<title>{title}</title>{/if}
<g class:spin={spin > 0} class:spinReverse={spin < 0}>
<path d={path} />
</g>
</svg>
<style>
svg {
display: inline;
vertical-align: middle;
fill: var(--color);
width: var(--size);
height: var(--size);
transform: rotate(var(--rotate)) scale(var(--scaleV), var(--scaleH));
}
/* If spin is strictly > 0, class spin is added to g and plays {spinFrames} in {spin-duration} seconds */
g.spin {
transform-origin: center;
animation: spinFrames linear var(--spin-duration) infinite;
}
/* If spin is strictly < 0, class spinReverse is added to g and plays {spinReverseFrames} in {spin-duration} seconds */
g.spinReverse {
transform-origin: center;
animation: spinReverseFrames linear var(--spin-duration) infinite;
}
@keyframes spinFrames {
to {
transform: rotate(360deg);
}
}
@keyframes spinReverseFrames {
to {
transform: rotate(-360deg);
}
}
</style>

View file

@ -2,12 +2,13 @@
import { page } from "$app/stores";
export let route: string;
export let href: string;
</script>
<div>
<a
class="btn btn-ghost drawer-button font-normal decoration-primary decoration-4 underline-offset-4"
class:underline={$page.route.id === route}
href={route}><slot /></a
{href}><slot /></a
>
</div>

View file

@ -0,0 +1,83 @@
import type { Adapter, AdapterAccount, AdapterUser } from "@auth/core/adapters";
import type { Account, User, PrismaClient } from "@prisma/client";
/// Map database user (with numeric ID) to authjs user
function mapUser(user: User): AdapterUser {
return {
id: user.id.toString(),
name: user.name,
email: user.email!,
emailVerified: user.emailVerified,
};
}
function mapUserOpt(user: User | null | undefined): AdapterUser | null {
if (!user) return null;
return mapUser(user);
}
function mapAccount(account: Account): AdapterAccount {
return {
userId: account.userId.toString(),
type: account.type as "oauth" | "oidc" | "email",
provider: account.provider,
providerAccountId: account.providerAccountId,
refresh_token: account.refresh_token ?? undefined,
access_token: account.access_token ?? undefined,
expires_at: account.expires_at ?? undefined,
token_type: account.token_type ?? undefined,
scope: account.scope ?? undefined,
id_token: account.id_token ?? undefined,
};
}
export function PrismaAdapter(p: PrismaClient): Adapter {
return {
createUser: async (data) =>
mapUser(
await p.user.create({
data: {
name: data.name,
email: data.email,
emailVerified: data.emailVerified,
},
})
),
getUser: async (id) =>
mapUserOpt(await p.user.findUnique({ where: { id: parseInt(id) } })),
getUserByEmail: async (email) =>
mapUserOpt(await p.user.findUnique({ where: { email } })),
async getUserByAccount(provider_providerAccountId) {
const account = await p.account.findUnique({
where: { provider_providerAccountId },
select: { user: true },
});
return mapUserOpt(account?.user) ?? null;
},
updateUser: async ({ id, ...data }) =>
mapUser(await p.user.update({ where: { id: parseInt(id) }, data })),
deleteUser: async (id) =>
mapUser(await p.user.delete({ where: { id: parseInt(id) } })),
linkAccount: async (data) =>
mapAccount(
await p.account.create({
data: {
userId: parseInt(data.userId),
type: data.type,
provider: data.provider,
providerAccountId: data.providerAccountId,
refresh_token: data.refresh_token,
access_token: data.access_token,
expires_at: data.expires_at,
token_type: data.token_type,
scope: data.scope,
id_token: data.id_token,
},
})
),
unlinkAccount: (provider_providerAccountId) =>
p.account.delete({
where: { provider_providerAccountId },
}) as unknown as AdapterAccount,
};
}

View file

@ -5,7 +5,7 @@ const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: ["query"],
// log: ["query"],
});
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View file

@ -0,0 +1,17 @@
import type { Category, CategoryNew } from "$lib/shared/model";
import { ZCategoryNew } from "$lib/shared/model/validation";
import { prisma } from "$lib/server/prisma";
export async function newCategory(category: CategoryNew): Promise<number> {
const data = ZCategoryNew.parse(category);
const created = await prisma.category.create({ data });
return created.id;
}
export async function getCategory(id: number): Promise<Category> {
return prisma.category.findUniqueOrThrow({ where: { id } });
}
export async function getCategories(): Promise<Category[]> {
return prisma.category.findMany({ orderBy: { id: "asc" } });
}

View file

@ -0,0 +1,259 @@
import { prisma } from "$lib/server/prisma";
import type {
EntriesRequest,
Entry,
EntryExecutionNew,
EntryNew,
EntryVersionNew,
Pagination,
} from "$lib/shared/model";
import {
ZEntryExecutionNew,
ZEntryNew,
ZEntryVersionNew,
} from "$lib/shared/model/validation";
import { mapEntry } from "./mapping";
import { QueryBuilder } from "./util";
const USER_SELECT = { select: { id: true, name: true } };
export async function getEntry(id: number): Promise<Entry> {
const entry = await prisma.entry.findUniqueOrThrow({
where: { id },
include: {
patient: { include: { room: { include: { station: true } } } },
EntryVersion: {
include: { author: USER_SELECT, category: true },
orderBy: { createdAt: "desc" },
take: 1,
},
EntryExecution: {
include: { author: USER_SELECT },
orderBy: { createdAt: "desc" },
take: 1,
},
},
});
return mapEntry(entry);
}
export async function newEntry(authorId: number, entry: EntryNew): Promise<number> {
const data = ZEntryNew.parse(entry);
const created = await prisma.entry.create({
data: {
patientId: data.patientId,
EntryVersion: {
create: {
authorId,
...data.version,
},
},
},
});
return created.id;
}
export async function newEntryVersion(
authorId: number,
entryId: number,
version: EntryVersionNew
): Promise<number> {
const data = ZEntryVersionNew.parse(version);
const created = await prisma.entryVersion.create({
data: {
entryId,
authorId,
...data,
},
});
return created.id;
}
export async function newEntryExecution(
authorId: number,
entryId: number,
execution: EntryExecutionNew
): Promise<number> {
const data = ZEntryExecutionNew.parse(execution);
const created = await prisma.entryExecution.create({
data: {
entryId,
authorId,
...data,
},
});
return created.id;
}
export async function getEntries(req: EntriesRequest): Promise<Pagination<Entry>> {
const qb = new QueryBuilder(
`select
e.id,
e."createdAt",
ev.text,
ev."date",
ev.priority,
ev.id as "versionId",
ev."createdAt" as "versionCreatedAt",
vau.id as "versionAuthorId",
vau."name" as "versionAuthorName",
c.id as "categoryId",
c."name" as "categoryName",
c.color as "categoryColor",
ex.id as "executionId",
ex.text as "executionText",
ex."createdAt" as "executionCreatedAt",
xau.id as "executionAuthorId",
xau.name as "executionAuthorName",
p.id as "patientId",
p."firstName" as "patientFirstName",
p."lastName" as "patientLastName",
p.age as "patientAge",
p."createdAt" as "patientCreatedAt",
r.id as "roomId",
r."name" as "roomName",
s.id as "stationId",
s."name" as "stationName"`,
`from entries e
join entry_versions ev on
ev."entryId" = e.id
and ev.id = (
select
id
from
entry_versions ev2
where
ev2."entryId" = ev."entryId"
order by
ev2."createdAt" desc
limit 1)
join users vau on vau.id=ev."authorId"
left join categories c on c.id=ev."categoryId"
left join entry_execution ex on
ex."entryId" = e.id
and ex.id = (
select
id
from
entry_execution ex2
where
ex2."entryId" = ex."entryId"
order by
ex2."createdAt" desc
limit 1)
left join users xau on xau.id=ex."authorId"
join patients p on p.id = e."patientId"
join rooms r on r.id = p."roomId"
join stations s on s.id = r."stationId"`
);
// qb.addFilterIsNull("ex.id", req.filter?.done);
if (req.filter?.done === true) {
qb.addFilterClause("ex.id is not null");
} else if (req.filter?.done === false) {
qb.addFilterClause("ex.id is null");
}
qb.addFilterList("xau.id", req.filter?.executor);
qb.addFilterList("c.id", req.filter?.category);
qb.addFilterList("p.id", req.filter?.patient);
qb.addFilterList("s.id", req.filter?.station);
qb.addFilterList("r.id", req.filter?.room);
qb.addFilter("ev.priority", req.filter?.priority);
if (req.filter?.author) {
let author = req.filter?.author;
if (!Array.isArray(author)) {
author = [author];
}
qb.addFilterClause(
`${qb.pvar()}::integer[] && (select array_agg(ev2."authorId") from entry_versions ev2 where ev2."entryId"=e.id)`,
author
);
}
qb.setOrderClause(`order by e."createdAt" desc`);
if (req.pagination) qb.setPagination(req.pagination);
type RequestItem = {
id: number;
createdAt: Date;
text: string;
date: Date;
priority: boolean;
versionId: number;
versionCreatedAt: Date;
versionAuthorId: number;
versionAuthorName: string;
categoryId: number;
categoryName: string;
categoryColor: string;
executionId: number;
executionText: string;
executionCreatedAt: Date;
executionAuthorId: number;
executionAuthorName: string;
patientId: number;
patientFirstName: string;
patientLastName: string;
patientAge: number;
patientCreatedAt: Date;
roomId: number;
roomName: string;
stationId: number;
stationName: string;
};
const [res, countRes] = (await Promise.all([
prisma.$queryRawUnsafe(qb.getQuery(), ...qb.getParams()),
prisma.$queryRawUnsafe(qb.getCountQuery(), ...qb.getCountParams()),
])) as [RequestItem[], { count: bigint }[]];
const total = Number(countRes[0].count);
const items: Entry[] = res.map((item) => {
return {
id: item.id,
patient: {
id: item.patientId,
firstName: item.patientFirstName,
lastName: item.patientLastName,
createdAt: item.patientCreatedAt,
age: item.patientAge,
room: {
id: item.roomId,
name: item.roomName,
station: { id: item.stationId, name: item.stationName },
},
},
createdAt: item.createdAt,
currentVersion: {
id: item.versionId,
text: item.text,
date: item.date,
category: item.categoryId
? {
id: item.categoryId,
name: item.categoryName,
color: item.categoryColor,
description: null,
}
: null,
priority: item.priority,
author: { id: item.versionAuthorId, name: item.versionAuthorName },
createdAt: item.createdAt,
},
execution: item.executionId
? {
id: item.executionId,
author: { id: item.executionAuthorId, name: item.executionAuthorName },
text: item.executionText,
createdAt: item.executionCreatedAt,
}
: null,
};
});
return { items, offset: qb.getOffset(), total };
}

View file

@ -0,0 +1,5 @@
export * from "./entry";
export * from "./category";
export * from "./patient";
export * from "./user";
export * from "./room";

View file

@ -0,0 +1,83 @@
import type { Entry, Patient, User, UserTag, Room } from "$lib/shared/model";
import type {
Patient as DbPatient,
Room as DbRoom,
Station as DbStation,
User as DbUser,
Entry as DbEntry,
EntryVersion as DbEntryVersion,
EntryExecution as DbEntryExecution,
Category as DbCategory,
} from "@prisma/client";
type DbRoomLn = DbRoom & { station: DbStation };
type DbPatientLn = DbPatient & { room: DbRoomLn | null };
type DbEntryVersionLn = DbEntryVersion & {
category: DbCategory | null;
author: UserTag;
};
type DbEntryExecutionLn = DbEntryExecution & { author: UserTag };
export function mapPatient(patient: DbPatientLn): Patient {
return {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
createdAt: patient.createdAt,
age: patient.age,
room: patient.room
? {
id: patient.room.id,
name: patient.room.name,
station: patient.room.station,
}
: null,
};
}
export function mapUser(user: DbUser): User {
return { id: user.id, name: user.name, email: user.email };
}
export function mapUserTag(user: DbUser): UserTag {
return { id: user.id, name: user.name };
}
export function mapRoom(room: DbRoomLn): Room {
return { id: room.id, name: room.name, station: room.station };
}
export function mapEntry(
entry: DbEntry & {
EntryVersion: DbEntryVersionLn[];
EntryExecution: DbEntryExecutionLn[];
patient: DbPatientLn;
}
): Entry {
const v = entry.EntryVersion[0];
if (!v) throw new Error("no version associated with that entry");
const x = entry.EntryExecution[0];
return {
id: entry.id,
patient: mapPatient(entry.patient),
createdAt: entry.createdAt,
currentVersion: {
id: v.id,
text: v.text,
date: v.date,
category: v.category,
priority: v.priority,
author: v.author,
createdAt: v.createdAt,
},
execution: x
? {
id: x.id,
author: x.author,
createdAt: x.createdAt,
text: x.text,
}
: null,
};
}

View file

@ -0,0 +1,55 @@
import type {
Patient,
PatientNew,
PatientsRequest,
Pagination,
} from "$lib/shared/model";
import { ZPatientNew } from "$lib/shared/model/validation";
import { prisma } from "$lib/server/prisma";
import { mapPatient } from "./mapping";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import { convertFilterList } from "./util";
export async function newPatient(patient: PatientNew): Promise<number> {
const data = ZPatientNew.parse(patient);
const created = await prisma.patient.create({ data });
return created.id;
}
export async function getPatient(id: number): Promise<Patient> {
const patient = await prisma.patient.findUniqueOrThrow({
where: { id },
include: {
room: { include: { station: true } },
},
});
return mapPatient(patient);
}
export async function getPatients(req: PatientsRequest): Promise<Pagination<Patient>> {
const offset = req.pagination?.offset || 0;
const where = {
roomId: convertFilterList(req.filter?.room),
room: {
stationId: convertFilterList(req.filter?.station),
},
};
const [patients, total] = await Promise.all([
prisma.patient.findMany({
where,
include: {
room: { include: { station: true } },
},
orderBy: { createdAt: "desc" },
skip: offset,
take: req.pagination?.limit || PAGINATION_LIMIT,
}),
prisma.patient.count({ where }),
]);
return {
items: patients.map(mapPatient),
offset,
total,
};
}

View file

@ -0,0 +1,44 @@
import type { RoomNew, Room, Station, StationNew } from "$lib/shared/model";
import { ZRoomNew, ZStationNew } from "$lib/shared/model/validation";
import { prisma } from "$lib/server/prisma";
import { mapRoom } from "./mapping";
export async function newStation(station: StationNew): Promise<number> {
const data = ZStationNew.parse(station);
const created = await prisma.station.create({ data });
return created.id;
}
export async function getStation(id: number): Promise<Station> {
return await prisma.station.findUniqueOrThrow({ where: { id } });
}
export async function getStations(): Promise<Station[]> {
return prisma.station.findMany({ orderBy: { id: "asc" } });
}
export async function newRoom(room: RoomNew): Promise<number> {
const data = ZRoomNew.parse(room);
const created = await prisma.room.create({ data });
return created.id;
}
export async function getRoom(id: number): Promise<Room> {
const room = await prisma.room.findUniqueOrThrow({
where: { id },
include: { station: true },
});
return {
id: room.id,
name: room.name,
station: room.station,
};
}
export async function getRooms(): Promise<Room[]> {
const rooms = await prisma.room.findMany({
include: { station: true },
orderBy: { name: "asc" },
});
return rooms.map(mapRoom);
}

View file

@ -0,0 +1,26 @@
import type { Pagination, User, UserTag, UsersRequest } from "$lib/shared/model";
import { prisma } from "$lib/server/prisma";
import { mapUser, mapUserTag } from "./mapping";
import { PAGINATION_LIMIT } from "$lib/shared/constants";
export async function getUser(id: number): Promise<User> {
const user = await prisma.user.findUniqueOrThrow({ where: { id } });
return mapUser(user);
}
export async function getUsers(req: UsersRequest): Promise<Pagination<UserTag>> {
const offset = req.pagination?.offset || 0;
const [users, total] = await Promise.all([
prisma.user.findMany({
orderBy: { id: "asc" },
skip: offset,
take: req.pagination?.limit || PAGINATION_LIMIT,
}),
prisma.user.count(),
]);
return {
items: users.map(mapUserTag),
offset,
total,
};
}

View file

@ -0,0 +1,20 @@
import { expect, test } from "vitest";
import { QueryBuilder } from "./util";
test("query builder", () => {
const qb = new QueryBuilder("select e.id, e.text, e.category", "from entries e");
qb.addFilterList("category", [1, 2, 3]);
qb.addFilter("text", "HelloWorld");
qb.setPagination({ limit: 20, offset: 10 });
const query = qb.getQuery();
expect(query).toBe(
"select e.id, e.text, e.category from entries e where category in $1 and text = $2 limit $3 offset $4"
);
const params = qb.getParams();
expect(params[0]).toStrictEqual([1, 2, 3]);
expect(params[1]).toBe("HelloWorld");
expect(params[2]).toBe(20);
expect(params[3]).toBe(10);
});

View file

@ -0,0 +1,100 @@
import { PAGINATION_LIMIT } from "$lib/shared/constants";
import type { FilterList, PaginationRequest } from "$lib/shared/model";
export function convertFilterList<T>(
fl: FilterList<T> | undefined
): { in: T[] } | T | undefined {
if (!fl) {
return undefined;
} else if (Array.isArray(fl)) {
return { in: fl };
} else {
return fl;
}
}
export class QueryBuilder {
private selectClause;
private fromClause;
private filterClauses: string[] = [];
private orderClause = "";
private params: unknown[] = [];
private nP = 0;
private limit = PAGINATION_LIMIT;
private offset = 0;
constructor(selectClause: string, fromClause: string) {
this.selectClause = selectClause;
this.fromClause = fromClause;
}
setPagination(pag: PaginationRequest) {
this.limit = pag.limit;
this.offset = pag.offset;
}
setOrderClause(orderClause: string) {
this.orderClause = orderClause;
}
/** Get the next parameter variable (e.g. $1) and increment the counter */
pvar(): string {
this.nP += 1;
return `$${this.nP}`;
}
addFilter(fname: string, val: unknown | undefined) {
if (val === undefined) return;
this.params.push(val);
this.filterClauses.push(`${fname} = ${this.pvar()}`);
}
addFilterClause(clause: string, ...params: unknown[]) {
this.filterClauses.push(clause);
this.params.push(...params);
}
addFilterList(fname: string, fl: FilterList<unknown> | undefined) {
if (fl === undefined) return;
this.params.push(fl);
if (Array.isArray(fl)) {
this.filterClauses.push(`${fname} in ${this.pvar()}`);
} else {
this.filterClauses.push(`${fname} = ${this.pvar()}`);
}
}
getQuery(): string {
const queryParts = [this.selectClause, this.fromClause];
if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and "));
}
if (this.orderClause.length > 0) queryParts.push(this.orderClause);
queryParts.push(`limit $${this.nP + 1} offset $${this.nP + 2}`);
return queryParts.join(" ");
}
getCountQuery(): string {
const queryParts = ["select count(*) as count", this.fromClause];
if (this.filterClauses.length > 0) {
queryParts.push("where " + this.filterClauses.join(" and "));
}
return queryParts.join(" ");
}
getParams(): unknown[] {
return [...this.params, this.limit, this.offset];
}
getCountParams(): unknown[] {
return this.params;
}
getOffset(): number {
return this.offset;
}
}

View file

@ -0,0 +1 @@
export const PAGINATION_LIMIT = 20;

View file

@ -0,0 +1,4 @@
export type Option<T> = T | null;
export * from "./model";
export * from "./requests";

View file

@ -0,0 +1,108 @@
import type { Option } from ".";
export type Pagination<T> = {
items: T[];
total: number;
offset: number;
};
export type User = {
id: number;
name: Option<string>;
email: Option<string>;
};
export type UserTag = {
id: number;
name: Option<string>;
};
export type Station = {
id: number;
name: string;
};
export type StationNew = {
name: string;
};
export type Room = {
id: number;
name: string;
station: Station;
};
export type RoomNew = {
name: string;
stationId: number;
};
export type Category = {
id: number;
name: string;
color: Option<string>;
description: Option<string>;
};
export type CategoryNew = {
name: string;
color: Option<string>;
description: Option<string>;
};
export type Patient = {
id: number;
firstName: string;
lastName: string;
age: Option<number>;
room: Option<Room>;
createdAt: Date;
};
export type PatientNew = {
firstName: string;
lastName: string;
age: Option<number>;
roomId: number;
};
export type Entry = {
id: number;
patient: Patient;
createdAt: Date;
currentVersion: EntryVersion;
execution: Option<EntryExecution>;
};
export type EntryNew = {
patientId: number;
version: EntryVersionNew;
};
export type EntryVersion = {
id: number;
text: string;
date: Date;
category: Option<Category>;
priority: boolean;
author: UserTag;
createdAt: Date;
};
export type EntryVersionNew = {
text: string;
date: Date;
categoryId: Option<number>;
priority: boolean;
};
export type EntryExecution = {
id: number;
author: UserTag;
text: string;
createdAt: Date;
};
export type EntryExecutionNew = {
text: string;
};

View file

@ -0,0 +1,34 @@
export type PaginationRequest = {
limit: number;
offset: number;
};
export type FilterList<T> = T | T[];
export type EntriesFilter = Partial<{
done: boolean;
author: FilterList<number>;
executor: FilterList<number>;
category: FilterList<number>;
patient: FilterList<number>;
station: FilterList<number>;
room: FilterList<number>;
priority: boolean;
}>;
export type EntriesRequest = Partial<{
filter: EntriesFilter;
pagination: PaginationRequest;
}>;
export type UsersRequest = Partial<{ pagination: PaginationRequest }>;
export type PatientsFilter = Partial<{
station: FilterList<number>;
room: FilterList<number>;
}>;
export type PatientsRequest = Partial<{
filter: PatientsFilter;
pagination: PaginationRequest;
}>;

View file

@ -0,0 +1,55 @@
import { z } from "zod";
import { implement } from "$lib/shared/util/zod";
import type {
CategoryNew,
EntryExecutionNew,
EntryNew,
EntryVersionNew,
PatientNew,
RoomNew,
StationNew,
} from ".";
const ZEntityId = z.number().int().nonnegative();
const ZNameString = z.string().min(1).max(200).trim();
const ZTextString = z.string().trim();
export const ZStationNew = implement<StationNew>().with({ name: ZNameString });
export const ZRoomNew = implement<RoomNew>().with({
name: ZNameString,
stationId: ZEntityId,
});
export const ZCategoryNew = implement<CategoryNew>().with({
name: ZNameString,
description: ZTextString.nullable(),
color: z
.string()
.regex(/[0-9A-Fa-f]{6}/)
.toUpperCase()
.nullable(),
});
export const ZPatientNew = implement<PatientNew>().with({
firstName: ZNameString,
lastName: ZNameString,
age: z.number().int().nonnegative().lt(200).nullable(),
roomId: ZEntityId,
});
export const ZEntryVersionNew = implement<EntryVersionNew>().with({
text: ZTextString,
date: z.date(),
categoryId: ZEntityId.nullable(),
priority: z.boolean(),
});
export const ZEntryNew = implement<EntryNew>().with({
patientId: ZEntityId,
version: ZEntryVersionNew,
});
export const ZEntryExecutionNew = implement<EntryExecutionNew>().with({
text: ZTextString,
});

View file

@ -0,0 +1,25 @@
import { z } from "zod";
// Source: https://github.com/colinhacks/zod/discussions/1928
type Implements<Model> = {
[key in keyof Model]-?: undefined extends Model[key]
? null extends Model[key]
? z.ZodNullableType<z.ZodOptionalType<z.ZodType<Model[key]>>>
: z.ZodOptionalType<z.ZodType<Model[key]>>
: null extends Model[key]
? z.ZodNullableType<z.ZodType<Model[key]>>
: z.ZodType<Model[key]>;
};
export function implement<Model = never>() {
return {
with: <
Schema extends Implements<Model> & {
[unknownKey in Exclude<keyof Schema, keyof Model>]: never;
},
>(
schema: Schema
) => z.object(schema),
};
}

View file

@ -2,6 +2,9 @@
import { page } from "$app/stores";
import NavLink from "$lib/components/ui/NavLink.svelte";
import { signOut } from "@auth/sveltekit/client";
import { mdiAccount, mdiHome } from "@mdi/js";
import Icon from "$lib/components/ui/Icon.svelte";
</script>
<div
@ -10,15 +13,21 @@
>
<nav class="navbar w-full">
<div class="flex flex-1">
<NavLink route="/">Visitenbuch</NavLink>
<NavLink route="/plan">Planung</NavLink>
<NavLink route="/visit">Visite</NavLink>
<NavLink route="/(app)" href="/"
><Icon
path={mdiHome}
cls={$page.route.id === "/(app)" ? "text-primary" : ""}
/></NavLink
>
<NavLink route="/(app)/plan" href="/plan">Planung</NavLink>
<NavLink route="/(app)/visit" href="/visit">Visite</NavLink>
</div>
<div class="flex-0">
{#if $page.data.session?.user}
<div class="dropdown dropdown-hover">
<div class="dropdown dropdown-hover dropdown-end">
<div tabindex="0" role="button" class="btn btn-ghost">
{$page.data.session.user?.name}
<Icon path={mdiAccount} />
<span class="hidden md:inline">{$page.data.session.user?.name}</span>
</div>
<!-- svelte-ignore a11y-no-noninteractive-tabindex -->
<ul

View file

@ -1,7 +1,5 @@
<script>
import { page } from "$app/stores";
console.log("hello");
</script>
<div class="prose">

View file

@ -1,5 +1,5 @@
<script lang="ts">
import "../app.pcss";
import "../app.pcss"; // eslint-disable-line no-relative-import-paths/no-relative-import-paths
</script>
<div class="bg-base-100 text-base-content">

View file

@ -1,5 +1,5 @@
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/kit/vite";
import adapter from "@sveltejs/adapter-node";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
@ -8,10 +8,10 @@ const config = {
preprocess: [vitePreprocess({})],
kit: {
// adapter-auto only supports some environments, see https://kit.svelte.dev/docs/adapter-auto for a list.
// If your environment is not supported or you settled on a specific environment, switch out the adapter.
// See https://kit.svelte.dev/docs/adapters for more information about adapters.
adapter: adapter(),
adapter: adapter({
precompress: true,
}),
},
};

53
tests/helpers/reset-db.ts Normal file
View file

@ -0,0 +1,53 @@
import { PrismaClient } from "@prisma/client";
import { CATEGORIES } from "./testdata";
const prisma = new PrismaClient();
export default async () => {
await prisma.$transaction([
prisma.entryExecution.deleteMany(),
prisma.entryVersion.deleteMany(),
prisma.entry.deleteMany(),
prisma.patient.deleteMany(),
prisma.room.deleteMany(),
prisma.station.deleteMany(),
prisma.category.deleteMany(),
prisma.user.deleteMany(),
prisma.user.createMany({
data: [
{ id: 1, name: "Sven Schulz", email: "sven.schulz@example.com" },
{ id: 2, name: "Sabrina Loewe", email: "sabrina.loewe@example.com" },
],
}),
prisma.category.createMany({ data: CATEGORIES }),
prisma.station.createMany({
data: [
{ id: 1, name: "S1" },
{ id: 2, name: "S2" },
],
}),
prisma.room.createMany({
data: [
{ id: 1, name: "R1.1", stationId: 1 },
{ id: 2, name: "R1.2", stationId: 1 },
{ id: 3, name: "R2.1", stationId: 2 },
],
}),
prisma.patient.createMany({
data: [
{ id: 1, firstName: "Andreas", lastName: "Bergmann", age: 22, roomId: 1 },
{ id: 2, firstName: "Manuela", lastName: "Kortig", age: 41, roomId: 2 },
{ id: 3, firstName: "Markus", lastName: "Schuster", age: 50, roomId: 3 },
],
}),
prisma.$executeRaw`alter sequence users_id_seq restart with 3`,
prisma.$executeRaw`alter sequence categories_id_seq restart with 7`,
prisma.$executeRaw`alter sequence stations_id_seq restart with 3`,
prisma.$executeRaw`alter sequence rooms_id_seq restart with 4`,
prisma.$executeRaw`alter sequence patients_id_seq restart with 4`,
prisma.$executeRaw`alter sequence entry_execution_id_seq restart with 1`,
prisma.$executeRaw`alter sequence entry_versions_id_seq restart with 1`,
prisma.$executeRaw`alter sequence entries_id_seq restart with 1`,
]);
};

7
tests/helpers/setup.ts Normal file
View file

@ -0,0 +1,7 @@
import resetDb from "./reset-db";
import { beforeEach } from "vitest";
beforeEach(async (x) => {
await resetDb();
});

43
tests/helpers/testdata.ts Normal file
View file

@ -0,0 +1,43 @@
import type { Category, Station } from "$lib/shared/model";
export const S1: Station = { id: 1, name: "S1" };
export const S2: Station = { id: 2, name: "S2" };
export const CATEGORIES: Category[] = [
{
id: 1,
name: "Laborabnahme",
description: "Blutabnahme zur Untersuchung im Labor",
color: "FF0000",
},
{
id: 2,
name: "Untersuchungen",
description: "Durchführung von Untersuchungen",
color: "00AA00",
},
{
id: 3,
name: "Medikationsumstellung",
description: null,
color: "FF00FF",
},
{
id: 4,
name: "Klinische Visite",
description: null,
color: "00FFFF",
},
{
id: 5,
name: "Entlassung",
description: "Entlassung eines Patienten aus dem KH",
color: "55AAFF",
},
{
id: 6,
name: "Sonstiges",
description: null,
color: "444444",
},
];

View file

@ -0,0 +1,23 @@
import { getCategories, getCategory, newCategory } from "$lib/server/query";
import { expect, test } from "vitest";
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
import { CATEGORIES } from "../../helpers/testdata";
test("create category", async () => {
const data = {
name: "Test",
description: "Hello World",
color: "AABB01",
};
const cId = await newCategory(data);
expect(cId).gt(0);
const category = await getCategory(cId);
expect(category).toMatchObject(data);
expect(category.id).toBe(cId);
});
test("get categories", async () => {
const categories = await getCategories();
expect(categories).toStrictEqual(CATEGORIES);
});

View file

@ -0,0 +1,162 @@
import {
getEntries,
getEntry,
newEntry,
newEntryExecution,
newEntryVersion,
} from "$lib/server/query";
import { expect, test } from "vitest";
const TEST_VERSION = {
categoryId: 1,
text: "10ml Blut abnehmen",
date: new Date(2024, 1, 1),
priority: false,
};
test("create entry", async () => {
const eId = await newEntry(1, {
patientId: 1,
version: TEST_VERSION,
});
expect(eId).gt(0);
const entry = await getEntry(eId);
expect(entry.patient.id).toBe(1);
expect(entry.execution).toBeNull();
expect(entry.currentVersion.id).gt(0);
expect(entry.currentVersion.category?.id).toBe(TEST_VERSION.categoryId);
expect(entry.currentVersion.category?.name).toBe("Laborabnahme");
expect(entry.currentVersion.text).toBe(TEST_VERSION.text);
expect(entry.currentVersion.date).toStrictEqual(TEST_VERSION.date);
expect(entry.currentVersion.priority).toBe(TEST_VERSION.priority);
});
test("create extry version", async () => {
const eId = await newEntry(1, {
patientId: 1,
version: TEST_VERSION,
});
const text = "10ml Blut abnehmen\n\nPS: Nadel nicht vergessen";
await newEntryVersion(1, eId, {
...TEST_VERSION,
text,
});
const entry = await getEntry(eId);
expect(entry.currentVersion.text).toBe(text);
});
test("create extry execution", async () => {
const eId = await newEntry(1, {
patientId: 1,
version: TEST_VERSION,
});
const text = "Blutabnahme erledigt.";
await newEntryExecution(1, eId, { text });
const entry = await getEntry(eId);
expect(entry.execution?.author.id).toBe(1);
expect(entry.execution?.text).toBe(text);
});
async function insertTestEntries() {
// Create some entries
const eId1 = await newEntry(1, {
patientId: 1,
version: TEST_VERSION,
});
const eId2 = await newEntry(1, {
patientId: 2,
version: {
text: "Carrot cake jelly-o bonbon toffee chocolate.",
date: new Date(2024, 1, 5),
priority: false,
categoryId: null,
},
});
const eId3 = await newEntry(1, {
patientId: 1,
version: {
text: "Cheesecake danish donut oat cake caramels.",
date: new Date(2024, 1, 6),
priority: false,
categoryId: null,
},
});
// Update an entry
await newEntryVersion(2, eId1, {
categoryId: 3,
text: "Hello World",
date: new Date(2024, 1, 1),
priority: true,
});
// Execute entries
await newEntryExecution(1, eId2, { text: "Some execution txt" });
await newEntryExecution(2, eId3, { text: "More execution txt" });
return { eId1, eId2, eId3 };
}
test("get entries", async () => {
const { eId1, eId2, eId3 } = await insertTestEntries();
const entries = await getEntries({});
expect(entries.items).length(3);
expect(entries.total).toBe(3);
// Pagination
const entriesLim2 = await getEntries({ pagination: { limit: 2, offset: 0 } });
expect(entriesLim2.items).length(2);
expect(entriesLim2.total).toBe(3);
const entriesLim2Offset = await getEntries({ pagination: { limit: 2, offset: 2 } });
expect(entriesLim2Offset.items).length(1);
expect(entriesLim2Offset.offset).toBe(2);
expect(entriesLim2Offset.total).toBe(3);
// Filter by category
const entriesCategory = await getEntries({ filter: { category: 3 } });
expect(entriesCategory.items).length(1);
expect(entriesCategory.total).toBe(1);
expect(entriesCategory.items[0].id).toBe(eId1);
// Filter by author
const entriesAuthor = await getEntries({ filter: { author: 2 } });
expect(entriesAuthor.items).length(1);
expect(entriesAuthor.total).toBe(1);
expect(entriesAuthor.items[0].id).toBe(eId1);
// Filter by executor
const entriesExecutor = await getEntries({ filter: { executor: 1 } });
expect(entriesExecutor.items).length(1);
expect(entriesExecutor.total).toBe(1);
expect(entriesExecutor.items[0].id).toBe(eId2);
// Filter by patient
const entriesPatient = await getEntries({ filter: { patient: 1 } });
expect(entriesPatient.items).length(2);
expect(entriesPatient.total).toBe(2);
expect(entriesPatient.items[0].id).toBe(eId3);
expect(entriesPatient.items[1].id).toBe(eId1);
// Filter by room
const entriesRoom = await getEntries({ filter: { room: 1 } });
expect(entriesRoom).toStrictEqual(entriesPatient);
// Filter done
const entriesDone = await getEntries({ filter: { done: true } });
expect(entriesDone.items).length(2);
expect(entriesDone.total).toBe(2);
expect(entriesDone.items[0].id).toBe(eId3);
expect(entriesDone.items[1].id).toBe(eId2);
// Filter by priority
const entriesPrio = await getEntries({ filter: { priority: true } });
expect(entriesPrio.items).length(1);
expect(entriesPrio.total).toBe(1);
expect(entriesPrio.items[0].id).toBe(eId1);
});

View file

@ -0,0 +1,74 @@
import { newPatient, getPatient, getPatients } from "$lib/server/query";
import { expect, test } from "vitest";
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
import { S1, S2 } from "../../helpers/testdata";
test("create patient", async () => {
const pId = await newPatient({
firstName: "Max",
lastName: "Müller",
age: 31,
roomId: 1,
});
const patient = await getPatient(pId);
expect(patient).toMatchObject({
id: pId,
firstName: "Max",
lastName: "Müller",
age: 31,
room: { id: 1, name: "R1.1", station: S1 },
});
});
test("get patients", async () => {
const patients = await getPatients({});
expect(patients).toMatchObject({
items: [
{
id: 1,
firstName: "Andreas",
lastName: "Bergmann",
age: 22,
room: { id: 1, name: "R1.1", station: S1 },
},
{
id: 2,
firstName: "Manuela",
lastName: "Kortig",
age: 41,
room: { id: 2, name: "R1.2", station: S1 },
},
{
id: 3,
firstName: "Markus",
lastName: "Schuster",
age: 50,
room: { id: 3, name: "R2.1", station: S2 },
},
],
offset: 0,
total: 3,
});
});
test("get patients (pagination)", async () => {
const patients = await getPatients({ pagination: { offset: 1, limit: 100 } });
expect(patients.items).length(2);
expect(patients.items[0].id).toBe(2);
expect(patients.items[1].id).toBe(3);
expect(patients.offset).toBe(1);
expect(patients.total).toBe(3);
});
test("get patients (by room)", async () => {
const patients = await getPatients({ filter: { room: 1 } });
expect(patients.items).length(1);
expect(patients.items[0].id).toBe(1);
});
test("get patients (by station)", async () => {
const patients = await getPatients({ filter: { station: 1 } });
expect(patients.items).length(2);
expect(patients.items[0].id).toBe(1);
expect(patients.items[1].id).toBe(2);
});

View file

@ -0,0 +1,54 @@
import {
getRoom,
getRooms,
getStation,
getStations,
newRoom,
newStation,
} from "$lib/server/query";
import type { Room, Station } from "$lib/shared/model";
import { expect, test } from "vitest";
// eslint-disable-next-line no-relative-import-paths/no-relative-import-paths
import { S1, S2 } from "../../helpers/testdata";
test("create station", async () => {
const sId = await newStation({ name: "S3" });
const station = await getStation(sId);
expect(station).toStrictEqual({ id: sId, name: "S3" } satisfies Station);
});
test("get stations", async () => {
const stations = await getStations();
expect(stations).toStrictEqual([S1, S2]);
});
test("create room", async () => {
const rId = await newRoom({ name: "A1", stationId: 1 });
const room = await getRoom(rId);
expect(room).toStrictEqual({
id: rId,
name: "A1",
station: S1,
} satisfies Room);
});
test("get rooms", async () => {
const rooms = await getRooms();
expect(rooms).toStrictEqual([
{
id: 1,
name: "R1.1",
station: S1,
},
{
id: 2,
name: "R1.2",
station: S1,
},
{
id: 3,
name: "R2.1",
station: S2,
},
] satisfies Room[]);
});

View file

@ -0,0 +1,29 @@
import { getUser, getUsers } from "$lib/server/query";
import { expect, test } from "vitest";
test("get user", async () => {
const user = await getUser(1);
expect(user).toStrictEqual({
id: 1,
name: "Sven Schulz",
email: "sven.schulz@example.com",
});
});
test("get users", async () => {
const users = await getUsers({});
expect(users).toStrictEqual({
items: [
{
id: 1,
name: "Sven Schulz",
},
{
id: 2,
name: "Sabrina Loewe",
},
],
offset: 0,
total: 2,
});
});

View file

@ -1,8 +0,0 @@
import { expect, test } from "@playwright/test";
test("index page has expected h1", async ({ page }) => {
await page.goto("/");
await expect(
page.getByRole("heading", { name: "Welcome to SvelteKit" })
).toBeVisible();
});

View file

@ -0,0 +1,19 @@
// Prisma integration tests with Vitest
// https://www.prisma.io/blog/testing-series-3-aBUyF8nxAn
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [sveltekit()],
test: {
include: ["tests/integration/**/*.ts"],
minWorkers: 1,
maxWorkers: 1,
maxConcurrency: 1,
setupFiles: ["tests/helpers/setup.ts"],
env: {
DATABASE_URL: "postgresql://postgres:1234@localhost:5432/test?schema=public",
},
},
});