Reveiw
This commit is contained in:
parent
ad7968dadd
commit
5bf8e752e7
22 changed files with 81 additions and 112 deletions
|
|
@ -2,11 +2,10 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from fastapi import HTTPException, Request
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
from .db import db_connection
|
from .db import db_connection
|
||||||
|
from .models import _canonical_uuidv4
|
||||||
|
|
||||||
# Single generic detail used for ALL 401 responses. Per spec, the response
|
# Single generic detail used for ALL 401 responses. Per spec, the response
|
||||||
# must not distinguish between missing / malformed / unknown tokens — that
|
# must not distinguish between missing / malformed / unknown tokens — that
|
||||||
|
|
@ -21,26 +20,28 @@ def _unauthorized() -> HTTPException:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def get_current_user(request: Request) -> str:
|
def extract_bearer_token(request: Request) -> str | None:
|
||||||
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
"""Return the raw Bearer token from the Authorization header, or None."""
|
||||||
auth_header = request.headers.get("Authorization") or request.headers.get(
|
auth_header = request.headers.get("Authorization") or request.headers.get(
|
||||||
"authorization"
|
"authorization"
|
||||||
)
|
)
|
||||||
if not auth_header:
|
if not auth_header:
|
||||||
raise _unauthorized()
|
return None
|
||||||
|
|
||||||
parts = auth_header.split()
|
parts = auth_header.split()
|
||||||
if len(parts) != 2 or parts[0].lower() != "bearer":
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
||||||
raise _unauthorized()
|
return parts[1]
|
||||||
|
return None
|
||||||
|
|
||||||
token = parts[1]
|
|
||||||
|
def get_current_user(request: Request) -> str:
|
||||||
|
"""Dependency that extracts and validates a Bearer token, returns user_id."""
|
||||||
|
token = extract_bearer_token(request)
|
||||||
|
if token is None:
|
||||||
|
raise _unauthorized()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
u = uuid.UUID(token)
|
token = _canonical_uuidv4(token)
|
||||||
if u.version != 4:
|
except ValueError:
|
||||||
raise ValueError("Not v4")
|
|
||||||
token = str(u)
|
|
||||||
except (ValueError, AttributeError):
|
|
||||||
raise _unauthorized()
|
raise _unauthorized()
|
||||||
|
|
||||||
with db_connection() as conn:
|
with db_connection() as conn:
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@ from fastapi import Request, Response
|
||||||
from slowapi import Limiter
|
from slowapi import Limiter
|
||||||
from slowapi.util import get_remote_address
|
from slowapi.util import get_remote_address
|
||||||
|
|
||||||
|
from .auth import extract_bearer_token
|
||||||
|
|
||||||
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
PAYLOAD_LIMIT_BYTES = 2 * 1024 * 1024 # 2 MiB
|
||||||
|
|
||||||
_TOO_LARGE_BODY = json.dumps(
|
_TOO_LARGE_BODY = json.dumps(
|
||||||
|
|
@ -20,12 +22,7 @@ _TOO_LARGE_BODY = json.dumps(
|
||||||
|
|
||||||
def _get_token_or_ip(request: Request) -> str:
|
def _get_token_or_ip(request: Request) -> str:
|
||||||
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
"""Key function for rate limiting: use Bearer token if present, else IP."""
|
||||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
return extract_bearer_token(request) or get_remote_address(request)
|
||||||
if auth:
|
|
||||||
parts = auth.split()
|
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
||||||
return parts[1]
|
|
||||||
return get_remote_address(request)
|
|
||||||
|
|
||||||
|
|
||||||
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
limiter = Limiter(key_func=_get_token_or_ip, default_limits=[])
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ from hashlib import sha256
|
||||||
import structlog
|
import structlog
|
||||||
from fastapi import Request, Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
|
from .auth import extract_bearer_token
|
||||||
|
|
||||||
|
|
||||||
def configure_logging() -> None:
|
def configure_logging() -> None:
|
||||||
"""Configure structlog for JSON output."""
|
"""Configure structlog for JSON output."""
|
||||||
|
|
@ -38,12 +40,8 @@ async def request_logging_middleware(request: Request, call_next) -> Response:
|
||||||
structlog.contextvars.bind_contextvars(request_id=request_id)
|
structlog.contextvars.bind_contextvars(request_id=request_id)
|
||||||
|
|
||||||
# Extract user_id from Authorization header for logging (no DB call here)
|
# Extract user_id from Authorization header for logging (no DB call here)
|
||||||
auth = request.headers.get("Authorization") or request.headers.get("authorization")
|
token = extract_bearer_token(request)
|
||||||
user_id: str | None = None
|
user_id = token_log_id(token) if token else None
|
||||||
if auth:
|
|
||||||
parts = auth.split()
|
|
||||||
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|
||||||
user_id = token_log_id(parts[1])
|
|
||||||
|
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
response = await call_next(request)
|
response = await call_next(request)
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ def create_app() -> FastAPI:
|
||||||
if field
|
if field
|
||||||
)
|
)
|
||||||
if fields:
|
if fields:
|
||||||
detail_str = "Validation failed for: " + ", ".join(f for f in fields if f)
|
detail_str = "Validation failed for: " + ", ".join(fields)
|
||||||
else:
|
else:
|
||||||
detail_str = "Validation failed"
|
detail_str = "Validation failed"
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -116,7 +116,7 @@ def create_app() -> FastAPI:
|
||||||
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
code = STATUS_CODE_MAP.get(exc.status_code, "server_error")
|
||||||
detail = {"error": code, "detail": str(exc.detail)}
|
detail = {"error": code, "detail": str(exc.detail)}
|
||||||
|
|
||||||
headers = getattr(exc, "headers", None) or {}
|
headers = exc.headers or {}
|
||||||
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
return JSONResponse(status_code=exc.status_code, content=detail, headers=headers)
|
||||||
|
|
||||||
# Generic 500 handler
|
# Generic 500 handler
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,8 @@
|
||||||
-- Life Towers v4 initial schema.
|
-- Life Towers v4 initial schema.
|
||||||
-- SQLite with WAL mode and foreign keys enabled at connection time.
|
-- WAL mode, foreign keys, and busy_timeout are applied per-connection in
|
||||||
|
-- db._apply_pragmas(), so they are not (and need not be) set here.
|
||||||
-- All timestamps are unix epoch seconds (INTEGER).
|
-- All timestamps are unix epoch seconds (INTEGER).
|
||||||
|
|
||||||
PRAGMA journal_mode = WAL;
|
|
||||||
PRAGMA foreign_keys = ON;
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
|
|
|
||||||
|
|
@ -51,17 +51,6 @@ async def client(tmp_path: Path) -> AsyncGenerator[AsyncClient, None]:
|
||||||
db_module._DB_PATH = None
|
db_module._DB_PATH = None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Health
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_health(client: AsyncClient) -> None:
|
|
||||||
resp = await client.get("/api/v1/health")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json() == {"status": "ok"}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_spa_index_injects_absolute_open_graph_urls(
|
async def test_spa_index_injects_absolute_open_graph_urls(
|
||||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@
|
||||||
],
|
],
|
||||||
"analytics": false
|
"analytics": false
|
||||||
},
|
},
|
||||||
"newProjectRoot": "projects",
|
|
||||||
"projects": {
|
"projects": {
|
||||||
"frontend": {
|
"frontend": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import {
|
||||||
input,
|
input,
|
||||||
output,
|
output,
|
||||||
signal,
|
signal,
|
||||||
computed,
|
|
||||||
effect,
|
effect,
|
||||||
viewChild,
|
viewChild,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
|
|
@ -614,10 +613,12 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
const t = this.tags();
|
const t = this.tags();
|
||||||
untracked(() => {
|
untracked(() => {
|
||||||
const cur = this.newValue();
|
const cur = this.newValue();
|
||||||
if (!cur.tag && t.length > 0) {
|
if (!cur.tag) {
|
||||||
this.newValue.set({ ...cur, tag: t[0], is_done: this.defaultDone() });
|
this.newValue.set({
|
||||||
} else if (!cur.tag) {
|
...cur,
|
||||||
this.newValue.set({ ...cur, is_done: this.defaultDone() });
|
tag: t.length > 0 ? t[0] : '',
|
||||||
|
is_done: this.defaultDone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -648,19 +649,21 @@ export class BlockEditComponent implements AfterViewInit {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private colorOfTag(tag: string): string {
|
||||||
|
return tag ? getColorOfTag(tag, this.baseColor()) : 'transparent';
|
||||||
|
}
|
||||||
|
|
||||||
colorOfTagForBlock(id: string): string {
|
colorOfTagForBlock(id: string): string {
|
||||||
const v = this.editedFor(id);
|
return this.colorOfTag(this.editedFor(id).tag);
|
||||||
return v.tag ? getColorOfTag(v.tag, this.baseColor()) : 'transparent';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tagPlaceholder(fallback: string): string {
|
tagPlaceholder(fallback: string): string {
|
||||||
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
return this.tags().length === 0 ? 'No tags yet. Open to create one.' : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
colorOfNewTag = computed(() => {
|
colorOfNewTag(): string {
|
||||||
const t = this.newValue().tag;
|
return this.colorOfTag(this.newValue().tag);
|
||||||
return t ? getColorOfTag(t, this.baseColor()) : 'transparent';
|
}
|
||||||
});
|
|
||||||
|
|
||||||
formatDate(ts: number, compact = false): string {
|
formatDate(ts: number, compact = false): string {
|
||||||
const d = new Date(ts * 1000);
|
const d = new Date(ts * 1000);
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,6 @@ export interface UpdatePagePayload {
|
||||||
keep_tasks_open: boolean;
|
keep_tasks_open: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const UUIDV4_RE =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lt-settings',
|
selector: 'lt-settings',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
|
|
@ -255,10 +252,15 @@ export class SettingsComponent {
|
||||||
readonly hideCreateTowerButton = signal(false);
|
readonly hideCreateTowerButton = signal(false);
|
||||||
readonly keepTasksOpen = signal(false);
|
readonly keepTasksOpen = signal(false);
|
||||||
|
|
||||||
|
private static readonly UUIDV4_RE =
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
// Token-switch state
|
// Token-switch state
|
||||||
readonly tokenInput = signal('');
|
readonly tokenInput = signal('');
|
||||||
readonly tokenInputTouched = signal(false);
|
readonly tokenInputTouched = signal(false);
|
||||||
readonly isValidToken = computed(() => UUIDV4_RE.test(this.tokenInput()));
|
readonly isValidToken = computed(() =>
|
||||||
|
SettingsComponent.UUIDV4_RE.test(this.tokenInput()),
|
||||||
|
);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
|
|
|
||||||
|
|
@ -138,12 +138,14 @@ export class TowerSettingsComponent implements OnInit {
|
||||||
onSubmit(): void {
|
onSubmit(): void {
|
||||||
// Only the create flow reaches here via its Submit button; edit mode
|
// Only the create flow reaches here via its Submit button; edit mode
|
||||||
// auto-saves (and Enter on the single field is a harmless redundant save).
|
// auto-saves (and Enter on the single field is a harmless redundant save).
|
||||||
if (this.form.invalid) return;
|
this.tryEmitSave();
|
||||||
this.emitSave();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Emit a save only when the form is valid (skips e.g. an empty name). */
|
|
||||||
private autoSave(): void {
|
private autoSave(): void {
|
||||||
|
this.tryEmitSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
private tryEmitSave(): void {
|
||||||
if (this.form.invalid) return;
|
if (this.form.invalid) return;
|
||||||
this.emitSave();
|
this.emitSave();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -214,13 +214,16 @@ export class PageComponent {
|
||||||
|
|
||||||
onTrashEnter(): void {
|
onTrashEnter(): void {
|
||||||
this.nearTrashcan = true;
|
this.nearTrashcan = true;
|
||||||
const preview = document.querySelector('.cdk-drag-preview');
|
this.dragPreview()?.classList.add('trash-highlight');
|
||||||
if (preview) preview.classList.add('trash-highlight');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onTrashLeave(): void {
|
onTrashLeave(): void {
|
||||||
this.nearTrashcan = false;
|
this.nearTrashcan = false;
|
||||||
const preview = document.querySelector('.cdk-drag-preview');
|
this.dragPreview()?.classList.remove('trash-highlight');
|
||||||
if (preview) preview.classList.remove('trash-highlight');
|
}
|
||||||
|
|
||||||
|
/** The CDK drag preview currently in flight, if any. Matches legacy DOM-driven trash highlight. */
|
||||||
|
private dragPreview(): Element | null {
|
||||||
|
return document.querySelector('.cdk-drag-preview');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -71,12 +71,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
p {
|
|
||||||
strong {
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-buttons {
|
.confirm-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -38,9 +38,10 @@ export class PagesComponent implements OnDestroy {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
if (!this.store.loading() && this.store.pages().length === 0) {
|
const pages = this.store.pages();
|
||||||
|
if (!this.store.loading() && pages.length === 0) {
|
||||||
this.showWelcome.set(true);
|
this.showWelcome.set(true);
|
||||||
} else if (this.store.pages().length > 0) {
|
} else if (pages.length > 0) {
|
||||||
this.showWelcome.set(false);
|
this.showWelcome.set(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -89,9 +90,10 @@ export class PagesComponent implements OnDestroy {
|
||||||
});
|
});
|
||||||
|
|
||||||
readonly selectedPageIndex = computed(() => {
|
readonly selectedPageIndex = computed(() => {
|
||||||
|
const pages = this.store.pages();
|
||||||
const page = this.selectedPage();
|
const page = this.selectedPage();
|
||||||
if (!page) return -1;
|
if (!page) return -1;
|
||||||
return this.store.pages().findIndex((p) => p.id === page.id);
|
return pages.findIndex((p) => p.id === page.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
onSelectPage(index: number): void {
|
onSelectPage(index: number): void {
|
||||||
|
|
|
||||||
|
|
@ -185,9 +185,8 @@ export class ColorPickerComponent {
|
||||||
return `hsl(${h}, 70%, 55%)`;
|
return `hsl(${h}, 70%, 55%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
toCss(c: HslColor): string {
|
/** Re-exported so the template can call the utility directly. */
|
||||||
return toCss(c);
|
readonly toCss = toCss;
|
||||||
}
|
|
||||||
|
|
||||||
pickHue(h: number): void {
|
pickHue(h: number): void {
|
||||||
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
this.colorChange.emit({ h: h / 360, s: FIXED_S, l: FIXED_L });
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export interface DoubleSliderRange<T> {
|
||||||
* Two-thumb range slider — legacy "double-slider".
|
* Two-thumb range slider — legacy "double-slider".
|
||||||
* Hands an indexed range over an arbitrary values array; emits the
|
* Hands an indexed range over an arbitrary values array; emits the
|
||||||
* underlying values on each change. Labels magnetically lift as a thumb
|
* underlying values on each change. Labels magnetically lift as a thumb
|
||||||
* approaches them (rotated -45°), per the legacy.
|
* approaches them (rotated -30°), per the legacy.
|
||||||
*/
|
*/
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'lt-double-slider',
|
selector: 'lt-double-slider',
|
||||||
|
|
@ -228,7 +228,7 @@ export class DoubleSliderComponent {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Magnetic label position: returns a CSS `transform` that lifts the label
|
* Magnetic label position: returns a CSS `transform` that lifts the label
|
||||||
* upward and rotates -45° as a thumb approaches.
|
* upward and rotates -30° as a thumb approaches.
|
||||||
*/
|
*/
|
||||||
getOffset(index: number): string {
|
getOffset(index: number): string {
|
||||||
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
const labelIndex = index / Math.max(1, this.drawnLabels().length - 1);
|
||||||
|
|
|
||||||
|
|
@ -695,6 +695,8 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
if (this.isFirstRun && animateInitialStack && maxVisibleBlocks === null) {
|
||||||
this._visibleBlocks.set([]);
|
this._visibleBlocks.set([]);
|
||||||
this.hiddenBlockCount.set(0);
|
this.hiddenBlockCount.set(0);
|
||||||
|
this.prevDoneIds = allDone.map((b) => b.id);
|
||||||
|
this.isFirstRun = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -708,9 +710,6 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
newIds.length === 1 &&
|
newIds.length === 1 &&
|
||||||
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
prev.every((id) => ids.includes(id)); // no IDs disappeared
|
||||||
|
|
||||||
const inRange = (b: Block) =>
|
|
||||||
!range || (b.created_at >= range.from && b.created_at <= range.to);
|
|
||||||
|
|
||||||
const styled: StyledBlock[] = [];
|
const styled: StyledBlock[] = [];
|
||||||
for (const b of allDone) {
|
for (const b of allDone) {
|
||||||
if (range && b.created_at < range.from) {
|
if (range && b.created_at < range.from) {
|
||||||
|
|
@ -755,7 +754,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
this.hiddenBlockCount.set(hiddenCount);
|
this.hiddenBlockCount.set(hiddenCount);
|
||||||
|
|
||||||
if (this.isFirstRun && animateInitialStack) {
|
if (this.isFirstRun && animateInitialStack) {
|
||||||
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1' && inRange(b));
|
const initialBlocks = visibleStyled.filter((b) => b._opacity === '1');
|
||||||
if (initialBlocks.length > 0) {
|
if (initialBlocks.length > 0) {
|
||||||
this.startDescendAnimation(visibleStyled, initialBlocks);
|
this.startDescendAnimation(visibleStyled, initialBlocks);
|
||||||
this.prevDoneIds = ids;
|
this.prevDoneIds = ids;
|
||||||
|
|
@ -767,7 +766,7 @@ export class TowerComponent implements AfterViewInit, OnDestroy {
|
||||||
if (grewByOne) {
|
if (grewByOne) {
|
||||||
const newId = newIds[0];
|
const newId = newIds[0];
|
||||||
const newBlock = visibleStyled.find((b) => b.id === newId);
|
const newBlock = visibleStyled.find((b) => b.id === newId);
|
||||||
if (newBlock && inRange(newBlock)) {
|
if (newBlock) {
|
||||||
// Snap newly-added in-range block to start position, then on the next
|
// Snap newly-added in-range block to start position, then on the next
|
||||||
// paint flip it back to rest — that's what makes it visibly fall.
|
// paint flip it back to rest — that's what makes it visibly fall.
|
||||||
this.startDescendAnimation(visibleStyled, [newBlock]);
|
this.startDescendAnimation(visibleStyled, [newBlock]);
|
||||||
|
|
|
||||||
|
|
@ -333,20 +333,20 @@ import { A11yModule } from '@angular/cdk/a11y';
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.basic__label {
|
.basic__label,
|
||||||
|
.basic__text {
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-weight: inherit;
|
font-weight: inherit;
|
||||||
color: $text-color;
|
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
line-height: inherit;
|
line-height: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.basic__label {
|
||||||
|
color: $text-color;
|
||||||
|
}
|
||||||
|
|
||||||
.basic__text {
|
.basic__text {
|
||||||
font-family: inherit;
|
|
||||||
font-weight: inherit;
|
|
||||||
color: rgba($text-color, 0.82);
|
color: rgba($text-color, 0.82);
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
|
|
||||||
|
|
@ -21,23 +21,6 @@ describe('ApiService', () => {
|
||||||
http.verify();
|
http.verify();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gets health', async () => {
|
|
||||||
const promise = service.health();
|
|
||||||
const req = http.expectOne('/api/v1/health');
|
|
||||||
expect(req.request.method).toBe('GET');
|
|
||||||
req.flush({ status: 'ok' });
|
|
||||||
await expect(promise).resolves.toEqual({ status: 'ok' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('registers a token', async () => {
|
|
||||||
const promise = service.register('token-1');
|
|
||||||
const req = http.expectOne('/api/v1/register');
|
|
||||||
expect(req.request.method).toBe('POST');
|
|
||||||
expect(req.request.body).toEqual({ token: 'token-1' });
|
|
||||||
req.flush({ user_id: 'token-1' });
|
|
||||||
await expect(promise).resolves.toEqual({ user_id: 'token-1' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('gets data with a bearer token', async () => {
|
it('gets data with a bearer token', async () => {
|
||||||
const tree: TreeDto = { pages: [] };
|
const tree: TreeDto = { pages: [] };
|
||||||
const promise = service.getData('token-1');
|
const promise = service.getData('token-1');
|
||||||
|
|
|
||||||
|
|
@ -16,5 +16,5 @@ export function hash(s: string): number {
|
||||||
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||||
}
|
}
|
||||||
// Map the signed int32 to [0, 1) — same formula as legacy
|
// Map the signed int32 to [0, 1) — same formula as legacy
|
||||||
return h / (Math.pow(2, 32) - 2) + 0.5;
|
return h / (2 ** 32 - 2) + 0.5;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@
|
||||||
"src/**/*.ts"
|
"src/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.vitest.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,6 @@
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.d.ts",
|
"src/**/*.d.ts",
|
||||||
"src/**/*.spec.ts"
|
"src/**/*.vitest.ts"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default defineConfig({
|
||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'jsdom',
|
environment: 'jsdom',
|
||||||
include: ['src/**/*.vitest.ts', 'src/**/*.spec.vitest.ts'],
|
include: ['src/**/*.vitest.ts'],
|
||||||
setupFiles: ['./vitest.setup.ts'],
|
setupFiles: ['./vitest.setup.ts'],
|
||||||
coverage: {
|
coverage: {
|
||||||
provider: 'v8',
|
provider: 'v8',
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue