Add Timepoll availability polling application and image
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8
context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; }
name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18
POSTGIS_VERSION=3
VCHORD_VERSION=0.5.3
context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3
context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim
context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; }
name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
// Run against an isolated local database, not an existing production instance.
|
||||
const { chromium } = require("playwright");
|
||||
const assert = require("node:assert/strict");
|
||||
const base = process.env.TIMEPOLL_TEST_URL || "http://127.0.0.1:8094";
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({
|
||||
headless: true,
|
||||
executablePath: process.env.CHROMIUM_PATH,
|
||||
});
|
||||
const desktop = await browser.newContext({
|
||||
viewport: { width: 1440, height: 1050 },
|
||||
timezoneId: "Europe/Helsinki",
|
||||
});
|
||||
const errors = [];
|
||||
desktop.on("page", (p) =>
|
||||
p.on("pageerror", (error) => errors.push(error.message)),
|
||||
);
|
||||
const page = await desktop.newPage();
|
||||
const clipboardContext = await browser.newContext({
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
});
|
||||
const clipboardPage = await clipboardContext.newPage();
|
||||
const clipboardOrigin = new URL(base);
|
||||
clipboardOrigin.hostname = "127.0.0.1";
|
||||
await clipboardPage.goto(clipboardOrigin.origin + "/healthz");
|
||||
async function checkCopy(button, expected) {
|
||||
await page.bringToFront();
|
||||
if (await page.locator("#notice").isVisible())
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await button.click();
|
||||
await page.getByRole("alert").filter({ hasText: "Link copied." }).waitFor();
|
||||
await clipboardPage.bringToFront();
|
||||
assert.equal(
|
||||
await clipboardPage.evaluate(() => navigator.clipboard.readText()),
|
||||
expected,
|
||||
);
|
||||
await page.bringToFront();
|
||||
}
|
||||
await page.goto(base + "/");
|
||||
await page.getByRole("heading", { name: "Timepoll", exact: true }).waitFor();
|
||||
await page.screenshot({ path: "/tmp/timepoll-home.png", fullPage: true });
|
||||
await page.getByRole("link", { name: "Create poll", exact: true }).click();
|
||||
await page.getByLabel("Event name").fill("Autumn weekend");
|
||||
assert.equal(
|
||||
await page.locator("#event-zone").inputValue(),
|
||||
"Europe/Helsinki",
|
||||
);
|
||||
await page.locator("#date-from").fill("2026-10-23");
|
||||
await page.locator("#date-to").fill("2026-10-26");
|
||||
assert.equal(await page.locator("#date-count").textContent(), "0 added");
|
||||
await page.getByRole("button", { name: "Apply dates and times" }).click();
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "Add the pending dates" })
|
||||
.waitFor();
|
||||
assert.equal(await page.locator(".slot").count(), 0);
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await page.getByRole("button", { name: "Add dates", exact: true }).click();
|
||||
assert.equal(await page.locator("#date-count").textContent(), "4 added");
|
||||
assert.equal(await page.locator("#date-from").inputValue(), "");
|
||||
assert.equal(await page.locator("#start-time option").count(), 48);
|
||||
assert.equal(await page.locator("#end-time option").count(), 48);
|
||||
await page.locator("#slot-size").selectOption("15");
|
||||
await page.locator("#start-time").selectOption("09:15");
|
||||
await page.locator("#end-time").selectOption("16:45");
|
||||
await page.locator("#slot-size").selectOption("30");
|
||||
assert.equal(await page.locator("#start-time").inputValue(), "09:00");
|
||||
assert.equal(await page.locator("#end-time").inputValue(), "17:00");
|
||||
await page.getByRole("button", { name: "Apply dates and times" }).click();
|
||||
await page.locator(".slot").first().waitFor();
|
||||
assert.equal(await page.locator(".slot").count(), 64);
|
||||
assert.equal(await page.locator("tr.hour-mark").count(), 8);
|
||||
assert.equal(
|
||||
await page
|
||||
.locator("tr.hour-mark .time")
|
||||
.first()
|
||||
.evaluate((e) => getComputedStyle(e).fontWeight),
|
||||
"700",
|
||||
);
|
||||
await page.locator(".slot").first().click();
|
||||
assert.equal(await page.locator(".slot.blocked").count(), 1);
|
||||
await page.screenshot({ path: "/tmp/timepoll-editor.png", fullPage: true });
|
||||
await page.getByRole("button", { name: "Create poll", exact: true }).click();
|
||||
const publicLink = await page.getByLabel("Public voting link").inputValue();
|
||||
const adminLink = await page.getByLabel("Private admin link").inputValue();
|
||||
assert.match(
|
||||
new URL(publicLink).pathname,
|
||||
/^\/[A-Z0-9]{3}-[A-Z0-9]{3}-[A-Z0-9]{3}$/,
|
||||
);
|
||||
assert.match(
|
||||
new URL(adminLink).pathname,
|
||||
/^\/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/,
|
||||
);
|
||||
assert.equal(new URL(adminLink).hash, "");
|
||||
await checkCopy(
|
||||
page.getByRole("button", { name: "Copy private link" }),
|
||||
adminLink,
|
||||
);
|
||||
await checkCopy(
|
||||
page.getByRole("button", { name: "Copy public link" }),
|
||||
publicLink,
|
||||
);
|
||||
await page.getByRole("link", { name: "Open poll" }).click();
|
||||
await checkCopy(
|
||||
page.getByRole("button", { name: "Copy public link" }),
|
||||
publicLink,
|
||||
);
|
||||
await page.getByLabel("Your name").fill("Alice");
|
||||
await page.getByRole("button", { name: "Add availability" }).click();
|
||||
// Drag one contiguous day, without replacing the DOM mid-gesture.
|
||||
const available = page.locator(".slot:not([disabled])");
|
||||
const a = await available.nth(0).boundingBox();
|
||||
const b = await available.nth(8).boundingBox();
|
||||
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 15 });
|
||||
await page.mouse.up();
|
||||
await page.getByRole("button", { name: "Maybe", exact: true }).click();
|
||||
await available.nth(3).click();
|
||||
await page.getByRole("button", { name: "Save votes" }).click();
|
||||
await page.getByRole("button", { name: "Add availability" }).waitFor();
|
||||
assert.equal(
|
||||
await page.locator("#everyone").getAttribute("class"),
|
||||
"person active",
|
||||
);
|
||||
await page.getByRole("button", { name: "Everyone", exact: true }).click();
|
||||
await page.getByLabel("Your name").fill("alice");
|
||||
await page.getByRole("button", { name: "Add availability" }).click();
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "already in this poll" })
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
|
||||
const other = await desktop.newPage();
|
||||
await other.goto(publicLink);
|
||||
await other.getByLabel("Your name").fill("Bob");
|
||||
await other.getByRole("button", { name: "Add availability" }).click();
|
||||
const shadow = other.locator(".slot:not([disabled])").first();
|
||||
assert.match(
|
||||
await shadow.getAttribute("aria-label"),
|
||||
/Other participants: 1 yes, 0 maybe out of 1/,
|
||||
);
|
||||
assert.ok(
|
||||
await shadow.evaluate((e) => e.style.getPropertyValue("--peer-cell")),
|
||||
);
|
||||
const boundarySlot = Number(
|
||||
await other
|
||||
.locator(".peer-boundary:not([disabled])")
|
||||
.first()
|
||||
.getAttribute("data-slot"),
|
||||
);
|
||||
await other.locator(`[data-slot="${boundarySlot - 1800}"]`).click();
|
||||
await other.locator(`[data-slot="${boundarySlot}"]`).click();
|
||||
const peerBoundary = other.locator(`[data-slot="${boundarySlot}"]`);
|
||||
assert.ok(
|
||||
(await peerBoundary.getAttribute("class")).includes("peer-boundary"),
|
||||
);
|
||||
assert.equal(
|
||||
await peerBoundary.evaluate((e) => getComputedStyle(e).borderTopWidth),
|
||||
"2px",
|
||||
);
|
||||
assert.equal(
|
||||
await peerBoundary.evaluate((e) => getComputedStyle(e, "::after").opacity),
|
||||
"0.35",
|
||||
);
|
||||
await other.screenshot({
|
||||
path: "/tmp/timepoll-vote-shadow.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await other.emulateMedia({ colorScheme: "dark" });
|
||||
await other.screenshot({
|
||||
path: "/tmp/timepoll-vote-shadow-dark.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await other.emulateMedia({ colorScheme: "light" });
|
||||
await other.locator(".slot:not([disabled])").first().click();
|
||||
await other.getByRole("button", { name: "Save votes" }).click();
|
||||
await page
|
||||
.getByRole("button", { name: "Bob", exact: true })
|
||||
.waitFor({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Everyone", exact: true }).click();
|
||||
await page.locator(".slot:not([disabled])").first().hover();
|
||||
await page.locator("#popover").waitFor({ state: "visible" });
|
||||
await page.locator("#poll-title").hover();
|
||||
await page.locator("#popover").waitFor({ state: "hidden" });
|
||||
await page.locator(".slot:not([disabled])").first().hover();
|
||||
await page.locator("#popover").waitFor({ state: "visible" });
|
||||
await page.evaluate(() => window.scrollBy(0, 100));
|
||||
await page.locator("#popover").waitFor({ state: "hidden" });
|
||||
await page.locator(".slot:not([disabled])").first().click();
|
||||
await page.locator("#popover").waitFor({ state: "visible" });
|
||||
assert.equal(await page.locator("#popover .voter").count(), 2);
|
||||
await page.locator("#popover .voter").filter({ hasText: "Bob" }).click();
|
||||
await page.getByRole("button", { name: "Edit votes" }).click();
|
||||
assert.match(
|
||||
await page
|
||||
.locator(".slot:not([disabled])")
|
||||
.first()
|
||||
.getAttribute("aria-label"),
|
||||
/Other participants: 1 yes, 0 maybe out of 1/,
|
||||
);
|
||||
// Another editor updates the same participant. This draft must not win.
|
||||
await other.getByRole("button", { name: "Bob", exact: true }).click();
|
||||
await other.getByRole("button", { name: "Edit votes" }).click();
|
||||
await other.getByRole("button", { name: "Save votes" }).click();
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "changed elsewhere" })
|
||||
.waitFor({ timeout: 10000 });
|
||||
await page.getByRole("button", { name: "Save votes" }).click();
|
||||
await page
|
||||
.getByRole("alert")
|
||||
.filter({ hasText: "changed or removed elsewhere" })
|
||||
.waitFor();
|
||||
await page.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
assert.equal(
|
||||
await page.locator("#everyone").getAttribute("class"),
|
||||
"person active",
|
||||
);
|
||||
await page.getByRole("button", { name: "Add availability" }).waitFor();
|
||||
await page.getByRole("button", { name: "Dismiss" }).click();
|
||||
await page.screenshot({ path: "/tmp/timepoll-desktop.png", fullPage: true });
|
||||
|
||||
// Admin timezone change must retain UTC slots and update editable clocks.
|
||||
const admin = await desktop.newPage();
|
||||
await admin.goto(adminLink);
|
||||
const initial = await admin
|
||||
.locator(".slot")
|
||||
.first()
|
||||
.getAttribute("data-slot");
|
||||
await admin.locator("#event-zone").selectOption("America/New_York");
|
||||
assert.equal(
|
||||
await admin.locator(".slot").first().getAttribute("data-slot"),
|
||||
initial,
|
||||
);
|
||||
assert.equal(await admin.locator("#start-time").inputValue(), "02:00");
|
||||
await admin.getByRole("button", { name: "Save changes" }).click();
|
||||
await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor();
|
||||
await admin.locator("#slot-size").selectOption("15");
|
||||
await admin.locator(".grid.compact").waitFor();
|
||||
assert.ok((await admin.locator("tr.hour-mark").count()) > 0);
|
||||
assert.ok((await admin.locator(".slot").first().boundingBox()).height <= 21);
|
||||
await admin.getByRole("button", { name: "Save changes" }).click();
|
||||
await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor();
|
||||
|
||||
const mobileContext = await browser.newContext({
|
||||
viewport: { width: 390, height: 844 },
|
||||
isMobile: true,
|
||||
hasTouch: true,
|
||||
colorScheme: "dark",
|
||||
timezoneId: "America/Los_Angeles",
|
||||
});
|
||||
mobileContext.on("page", (p) =>
|
||||
p.on("pageerror", (error) => errors.push(error.message)),
|
||||
);
|
||||
const mobile = await mobileContext.newPage();
|
||||
await mobile.goto(publicLink);
|
||||
await mobile.locator(".slot").first().waitFor();
|
||||
assert.equal(
|
||||
await mobile.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
);
|
||||
await mobile.locator(".slot:not([disabled])").first().tap();
|
||||
await mobile.locator("#popover").waitFor({ state: "visible" });
|
||||
await mobile.getByRole("button", { name: "Close", exact: true }).tap();
|
||||
await mobile.screenshot({
|
||||
path: "/tmp/timepoll-mobile-dark.png",
|
||||
fullPage: true,
|
||||
});
|
||||
await mobile.getByLabel("Your name").fill("Casey");
|
||||
await mobile.getByRole("button", { name: "Add availability" }).tap();
|
||||
await mobile.locator(".slot:not([disabled])").first().tap();
|
||||
await mobile.getByRole("button", { name: "Save votes" }).tap();
|
||||
await mobile.getByRole("button", { name: "Add availability" }).waitFor();
|
||||
await page
|
||||
.getByRole("button", { name: "Casey", exact: true })
|
||||
.waitFor({ timeout: 10000 });
|
||||
await mobile.setViewportSize({ width: 320, height: 800 });
|
||||
await mobile.getByRole("button", { name: "Casey", exact: true }).tap();
|
||||
await mobile.getByRole("button", { name: "Edit votes" }).tap();
|
||||
assert.equal(
|
||||
await mobile.evaluate(() => document.documentElement.scrollWidth),
|
||||
320,
|
||||
);
|
||||
await mobile.getByRole("button", { name: "Pan grid" }).tap();
|
||||
assert.equal(await mobile.locator(".grid.editing").count(), 0);
|
||||
await mobile.getByRole("button", { name: "Cancel", exact: true }).tap();
|
||||
assert.equal(
|
||||
await mobile.locator("#everyone").getAttribute("class"),
|
||||
"person active",
|
||||
);
|
||||
await mobileContext.close();
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
publicLink,
|
||||
adminLink: "[private]",
|
||||
screenshots: "/tmp/timepoll-*.png",
|
||||
browserErrors: errors,
|
||||
}),
|
||||
);
|
||||
await browser.close();
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from itertools import pairwise
|
||||
|
||||
import pytest
|
||||
from domain import make_slots, remap_votes, resize_slots, summaries, votes_lost
|
||||
from fastapi.testclient import TestClient
|
||||
from server import create_app, normalize_code
|
||||
|
||||
|
||||
def stamp(value):
|
||||
return int(datetime.fromisoformat(value).replace(tzinfo=timezone.utc).timestamp())
|
||||
|
||||
|
||||
def test_dst_spring_skips_nonexistent_hour():
|
||||
slots = make_slots(["2026-03-29"], "02:00", "05:00", 30, "Europe/Helsinki")
|
||||
assert len(slots) == 4
|
||||
assert all(b - a == 1800 for a, b in pairwise(slots))
|
||||
|
||||
|
||||
def test_dst_fall_keeps_both_repeated_hours():
|
||||
slots = make_slots(["2026-10-25"], "02:00", "05:00", 30, "Europe/Helsinki")
|
||||
assert len(slots) == 8
|
||||
assert len(set(slots)) == 8
|
||||
assert all(b - a == 1800 for a, b in pairwise(slots))
|
||||
|
||||
|
||||
def test_slot_ending_at_spring_transition_is_not_lost():
|
||||
assert len(make_slots(["2026-03-29"], "02:00", "03:00", 30, "Europe/Helsinki")) == 2
|
||||
|
||||
|
||||
def test_half_hour_dst_change_never_overlaps_slots_or_escapes_range():
|
||||
slots = make_slots(["2026-04-05"], "01:30", "03:30", 60, "Australia/Lord_Howe")
|
||||
assert len(slots) == 2
|
||||
assert all(b - a >= 3600 for a, b in pairwise(slots))
|
||||
# The first repeated 01:45 precedes a reset to 01:30, outside this range.
|
||||
slots = make_slots(["2026-04-05"], "01:45", "02:45", 60, "Australia/Lord_Howe")
|
||||
assert slots == [stamp("2026-04-04T15:15")]
|
||||
|
||||
|
||||
def test_timezone_uses_date_not_current_offset():
|
||||
winter = make_slots(["2026-01-05"], "09:00", "10:00", 60, "Europe/Helsinki")
|
||||
summer = make_slots(["2026-07-05"], "09:00", "10:00", 60, "Europe/Helsinki")
|
||||
assert datetime.fromtimestamp(winter[0], timezone.utc).hour == 7
|
||||
assert datetime.fromtimestamp(summer[0], timezone.utc).hour == 6
|
||||
|
||||
|
||||
def test_midnight_and_quarter_hour_zone():
|
||||
slots = make_slots(["2026-01-05"], "23:00", "24:00", 30, "Asia/Kathmandu")
|
||||
assert slots == [stamp("2026-01-05T17:15"), stamp("2026-01-05T17:45")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end", [("17:00", "09:00"), ("09:00", "09:00"), ("09:00", "10:15")]
|
||||
)
|
||||
def test_invalid_ranges(start, end):
|
||||
with pytest.raises(ValueError):
|
||||
make_slots(["2026-01-01"], start, end, 30, "UTC")
|
||||
|
||||
|
||||
def test_vote_resize():
|
||||
assert remap_votes({"0": "yes", "1800": "yes"}, 30, [0], 60) == {"0": "yes"}
|
||||
assert remap_votes({"0": "yes"}, 30, [0], 60) == {"0": "maybe"}
|
||||
assert remap_votes({"0": "maybe"}, 30, [0], 60) == {"0": "maybe"}
|
||||
assert remap_votes({"0": "yes", "1800": "maybe"}, 30, [0], 60) == {"0": "maybe"}
|
||||
assert remap_votes({"0": "yes"}, 60, [0, 1800], 30) == {"0": "yes", "1800": "yes"}
|
||||
assert resize_slots([0, 1800, 7200, 9000], 30, 60) == [0, 7200]
|
||||
assert votes_lost({"0": "yes"}, 60, [0], 30)
|
||||
assert not votes_lost({"0": "yes"}, 60, [0, 1800], 30)
|
||||
|
||||
|
||||
def test_overlap_requires_same_people_and_counts_empty_voters():
|
||||
people = [
|
||||
{"id": 1, "votes": {"0": "yes", "1800": "yes"}},
|
||||
{"id": 2, "votes": {"0": "yes"}},
|
||||
{"id": 3, "votes": {"1800": "maybe"}},
|
||||
{"id": 4, "votes": {"0": "yes", "1800": "yes"}},
|
||||
]
|
||||
result = summaries([0, 1800], 30, people)
|
||||
assert result["yes"][0]["seconds"] == 0
|
||||
assert result["yes"][1]["seconds"] == 1800
|
||||
assert len(result["inclusive"][1]["stretches"]) == 2
|
||||
assert (
|
||||
result["inclusive"][1]["stretches"][0]["people"]
|
||||
!= result["inclusive"][1]["stretches"][1]["people"]
|
||||
)
|
||||
|
||||
|
||||
def test_overlap_gap_and_midnight():
|
||||
people = [{"id": 1, "votes": {"84600": "yes", "86400": "yes", "91800": "yes"}}]
|
||||
result = summaries([84600, 86400, 91800], 30, people)
|
||||
assert result["yes"][0]["seconds"] == 3600
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(tmp_path):
|
||||
with TestClient(create_app(tmp_path)) as client:
|
||||
yield client
|
||||
|
||||
|
||||
def settings(**kwargs):
|
||||
return dict(
|
||||
title="Weekend",
|
||||
timezone="Europe/Helsinki",
|
||||
fixed_timezone=False,
|
||||
minutes=30,
|
||||
slots=[1800000000, 1800001800, 1800003600, 1800005400],
|
||||
blocked=[],
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def create(client):
|
||||
response = client.post("/api/polls", json=settings())
|
||||
assert response.status_code == 201, response.text
|
||||
result = response.json()
|
||||
return result["code"], {"Authorization": "Bearer " + result["admin_token"]}
|
||||
|
||||
|
||||
def add(client, code, name="Alice", votes=None):
|
||||
response = client.post(
|
||||
f"/api/polls/{code}/people",
|
||||
json={"name": name, "votes": votes or {}, "schedule_revision": 1},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
return response.json()["id"]
|
||||
|
||||
|
||||
def test_codes_and_private_token_are_not_public(client, tmp_path):
|
||||
code, headers = create(client)
|
||||
assert len(code) == 9 and len(headers["Authorization"].split()[1]) == 12
|
||||
assert normalize_code("ab-oil xyz") == "AB011XYZ"
|
||||
response = client.get(f"/api/polls/{code.lower()}")
|
||||
assert response.status_code == 200
|
||||
assert "admin" not in response.text
|
||||
with sqlite3.connect(tmp_path / "timepoll.sqlite3") as db:
|
||||
assert headers["Authorization"].split()[1] not in str(
|
||||
db.execute("SELECT * FROM polls").fetchall()
|
||||
)
|
||||
assert client.get(f"/api/polls/{code}/admin").status_code == 403
|
||||
assert client.get(f"/api/polls/{code}/admin", headers=headers).status_code == 200
|
||||
|
||||
|
||||
def test_empty_duplicate_and_unavailable_votes(client):
|
||||
code, _ = create(client)
|
||||
add(client, code)
|
||||
duplicate = client.post(
|
||||
f"/api/polls/{code}/people",
|
||||
json={"name": " ALICE ", "votes": {}, "schedule_revision": 1},
|
||||
)
|
||||
assert duplicate.status_code == 409
|
||||
invalid = client.post(
|
||||
f"/api/polls/{code}/people",
|
||||
json={"name": "Bob", "votes": {"123": "yes"}, "schedule_revision": 1},
|
||||
)
|
||||
assert invalid.status_code == 422
|
||||
assert len(client.get(f"/api/polls/{code}").json()["people"]) == 1
|
||||
|
||||
|
||||
def test_conflicts_and_unrelated_edits(client):
|
||||
code, headers = create(client)
|
||||
alice = add(client, code)
|
||||
add(client, code, "Bob")
|
||||
body = {
|
||||
"name": "Alice",
|
||||
"votes": {"1800000000": "yes"},
|
||||
"schedule_revision": 1,
|
||||
"revision": 1,
|
||||
}
|
||||
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 200
|
||||
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 409
|
||||
edit = settings(settings_revision=1)
|
||||
edit.update(title="Renamed", timezone="America/New_York")
|
||||
assert (
|
||||
client.put(f"/api/polls/{code}", json=edit, headers=headers).status_code == 200
|
||||
)
|
||||
assert client.get(f"/api/polls/{code}").json()["schedule_revision"] == 1
|
||||
body["revision"] = 2
|
||||
assert client.put(f"/api/polls/{code}/people/{alice}", json=body).status_code == 200
|
||||
assert (
|
||||
client.delete(f"/api/polls/{code}/people/{alice}?revision=2").status_code == 409
|
||||
)
|
||||
assert (
|
||||
client.delete(f"/api/polls/{code}/people/{alice}?revision=3").status_code == 200
|
||||
)
|
||||
|
||||
|
||||
def test_admin_vote_loss_confirmation_and_conversion(client):
|
||||
code, headers = create(client)
|
||||
add(
|
||||
client,
|
||||
code,
|
||||
votes={"1800000000": "yes", "1800001800": "yes", "1800003600": "yes"},
|
||||
)
|
||||
edit = settings(settings_revision=1)
|
||||
edit.update(minutes=60, slots=[1800000000, 1800003600])
|
||||
response = client.put(f"/api/polls/{code}", json=edit, headers=headers)
|
||||
assert response.status_code == 200, response.text
|
||||
poll = client.get(f"/api/polls/{code}").json()
|
||||
assert poll["people"][0]["votes"] == {"1800000000": "yes", "1800003600": "maybe"}
|
||||
edit.update(settings_revision=2, blocked=[1800000000])
|
||||
response = client.put(f"/api/polls/{code}", json=edit, headers=headers)
|
||||
assert (
|
||||
response.status_code == 409 and response.json()["detail"]["code"] == "vote_loss"
|
||||
)
|
||||
assert client.get(f"/api/polls/{code}").json()["revision"] == poll["revision"]
|
||||
edit["confirm_loss"] = True
|
||||
assert (
|
||||
client.put(f"/api/polls/{code}", json=edit, headers=headers).status_code == 200
|
||||
)
|
||||
assert client.get(f"/api/polls/{code}").json()["people"][0]["votes"] == {
|
||||
"1800003600": "maybe"
|
||||
}
|
||||
|
||||
|
||||
def test_blocked_votes_and_changed_schedule_conflict(client):
|
||||
code, headers = create(client)
|
||||
edit = settings(settings_revision=1)
|
||||
edit["blocked"] = [1800000000]
|
||||
client.put(f"/api/polls/{code}", json=edit, headers=headers)
|
||||
body = {"name": "Alice", "votes": {"1800000000": "yes"}, "schedule_revision": 1}
|
||||
assert client.post(f"/api/polls/{code}/people", json=body).status_code == 409
|
||||
body["schedule_revision"] = 2
|
||||
assert client.post(f"/api/polls/{code}/people", json=body).status_code == 422
|
||||
|
||||
|
||||
def test_persistence(tmp_path):
|
||||
with TestClient(create_app(tmp_path)) as client:
|
||||
code, _ = create(client)
|
||||
add(client, code)
|
||||
with TestClient(create_app(tmp_path)) as restarted:
|
||||
assert (
|
||||
restarted.get(f"/api/polls/{code}").json()["people"][0]["name"] == "Alice"
|
||||
)
|
||||
|
||||
|
||||
def test_security_validation_and_health(client):
|
||||
assert client.get("/healthz").json() == {"status": "ok"}
|
||||
assert client.get("/").headers["x-frame-options"] == "DENY"
|
||||
assert (
|
||||
client.post(
|
||||
"/api/polls",
|
||||
json=settings(),
|
||||
headers={"Origin": "https://elsewhere.invalid"},
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert (
|
||||
client.post("/api/polls", content=b"x" * (1024 * 1024 + 1)).status_code == 413
|
||||
)
|
||||
invalid = settings()
|
||||
invalid["slots"] = [1800000000, 1800000060]
|
||||
assert client.post("/api/polls", json=invalid).status_code == 422
|
||||
invalid.update(timezone="../etc/passwd")
|
||||
assert client.post("/api/polls", json=invalid).status_code == 422
|
||||
|
||||
|
||||
def test_schedule_preview_and_resize(client):
|
||||
response = client.post(
|
||||
"/api/schedule",
|
||||
json={
|
||||
"dates": ["2026-10-25"],
|
||||
"start": "02:00",
|
||||
"end": "05:00",
|
||||
"minutes": 30,
|
||||
"timezone": "Europe/Helsinki",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["slots"]) == 8
|
||||
body = settings()
|
||||
body["minutes"] = 60
|
||||
response = client.post("/api/resize?old_minutes=30", json=body)
|
||||
assert response.json()["slots"] == [1800000000, 1800003600]
|
||||
|
||||
|
||||
def test_random_code_collision_is_retried(client, monkeypatch):
|
||||
code, headers = create(client)
|
||||
token = headers["Authorization"].split()[1]
|
||||
values = iter([code, token, "123456789", "123456789ABC"])
|
||||
monkeypatch.setattr("server.new_code", lambda length: next(values))
|
||||
response = client.post("/api/polls", json=settings())
|
||||
assert response.status_code == 201
|
||||
assert response.json()["code"] == "123456789"
|
||||
|
||||
|
||||
def test_root_public_and_private_links(client):
|
||||
code, headers = create(client)
|
||||
token = headers["Authorization"].split()[1]
|
||||
assert client.get(f"/{code}").status_code == 200
|
||||
assert client.get(f"/{token}").status_code == 200
|
||||
response = client.post("/api/admin/resolve", headers=headers)
|
||||
assert response.json() == {"code": code}
|
||||
assert client.post("/api/admin/resolve").status_code == 403
|
||||
assert (
|
||||
client.post(
|
||||
"/api/admin/resolve", headers={"Authorization": "Bearer " + code}
|
||||
).status_code
|
||||
== 403
|
||||
)
|
||||
assert client.get(f"/p/{code}").status_code == 404
|
||||
assert client.get(f"/admin/{code}").status_code == 404
|
||||
assert token not in client.get(f"/api/polls/{code}").text
|
||||
|
||||
|
||||
def test_resizing_rounds_local_range_outward():
|
||||
slots = make_slots(["2026-09-15"], "09:15", "10:45", 15, "Europe/Helsinki")
|
||||
assert resize_slots(slots, 15, 30, "Europe/Helsinki") == make_slots(
|
||||
["2026-09-15"], "09:00", "11:00", 30, "Europe/Helsinki"
|
||||
)
|
||||
# A non-whole-hour UTC offset must not change the local rounding origin.
|
||||
slots = make_slots(["2026-09-15"], "09:15", "10:45", 15, "Asia/Kathmandu")
|
||||
assert resize_slots(slots, 15, 60, "Asia/Kathmandu") == make_slots(
|
||||
["2026-09-15"], "09:00", "11:00", 60, "Asia/Kathmandu"
|
||||
)
|
||||
|
||||
|
||||
def test_resizing_merges_ranges_that_round_into_one_another():
|
||||
assert resize_slots([900, 2700], 15, 60) == [0]
|
||||
Reference in New Issue
Block a user