Workers P2: Zeitgrenze je Abruf und Sperre für frische JWTs nach einer 401-Folge
Endprüfung P2. Ein Abruf auf toter Verbindung (Rechner im Ruhezustand, Netzwechsel) blieb offen, und die Feed-Schleifen laufen mit einem Abruf je Schleife: die Seite stand still und zeigte veraltete Daten als aktuell. createWorkersApi bricht jetzt jeden Versuch (JWT holen, senden, Antwort lesen) nach 20 s mit "Der Hustler-Dienst ist gerade nicht erreichbar." (status 0) ab, auch wenn fetch oder createJWT das Signal nicht beachten. Außerdem erzwang jede 401-Antwort ein frisches createJWT, bei dauerhaftem 401 etwa 16 bis 20 Mal je Minute und Tab. Appwrite begrenzt createJWT je Nutzer; das Limit hätte auch Serverpuls, Finanzen und Vorschauen gesperrt. Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) gibt es 2 Minuten lang kein frisches JWT mehr, die Meldung "Anmeldung abgelaufen" bleibt; Erfolg hebt die Sperre auf. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
820ccd510d
commit
9ebc562c83
3 files changed
+234
-11
No files matched your search
+65
-10
@@ -7,6 +7,14 @@ export const BAD_REPLY_TEXT = 'Der Hustler-Dienst antwortet nicht richtig.'
|
|||||||
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
export const SESSION_TEXT = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||||||
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
|
export const FORBIDDEN_TEXT = 'Dafür fehlt dir die Berechtigung.'
|
||||||
|
|
||||||
|
// Zeitgrenze je Abruf (JWT holen, senden, Antwort lesen): ohne sie bliebe ein Abruf auf toter Verbindung (Rechner im Ruhezustand,
|
||||||
|
// Netzwechsel) minutenlang offen und der Feed (ein Abruf je Schleife) stünde still, ohne Fehlermeldung.
|
||||||
|
export const REQUEST_TIMEOUT_MS = 20000
|
||||||
|
// Nach einer 401-Folge (frisches JWT wird ebenfalls abgelehnt oder lässt sich nicht erzeugen) wird so lange kein frisches JWT mehr
|
||||||
|
// erzwungen: Appwrite begrenzt createJWT je Nutzer, und die Schleifen (5 s / 15 s) würden das Limit binnen Minuten aufbrauchen
|
||||||
|
// und damit auch Serverpuls, Finanzen und Vorschauen sperren. 2 Minuten ergeben höchstens 30 Versuche je Stunde und Tab.
|
||||||
|
export const AUTH_COOLDOWN_MS = 120000
|
||||||
|
|
||||||
// Pult-Status des Dienstes (Übersicht §4.11)
|
// Pult-Status des Dienstes (Übersicht §4.11)
|
||||||
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
|
export const PULT_STATUS = Object.freeze(['offen', 'in_arbeit', 'gesendet', 'verworfen', 'veraltet', 'erledigt'])
|
||||||
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
|
// Erlaubte Aktionen am Pult-Eintrag; alles andere wird abgewiesen, bevor ein Pfad entsteht
|
||||||
@@ -46,13 +54,35 @@ function pultStatusList(status) {
|
|||||||
return list
|
return list
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.fetch(...args) }) {
|
export function createWorkersApi({
|
||||||
|
getJwt,
|
||||||
|
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||||
|
timeoutMs = REQUEST_TIMEOUT_MS,
|
||||||
|
authCooldownMs = AUTH_COOLDOWN_MS,
|
||||||
|
now = Date.now,
|
||||||
|
}) {
|
||||||
|
let freshBlockedUntil = 0 // bis dahin wird nach einer 401-Antwort kein frisches JWT erzeugt (AUTH_COOLDOWN_MS)
|
||||||
|
|
||||||
|
// Führt fn(signal) mit Zeitgrenze aus: nach timeoutMs scheitert der Aufruf mit DOWN_TEXT (status 0) und das Signal wird
|
||||||
|
// abgebrochen. Das gilt auch, wenn fn das Signal nicht beachtet (hängendes createJWT, fetchImpl ohne Abbruch-Unterstützung).
|
||||||
|
function withDeadline(fn) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
let timer
|
||||||
|
const expired = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(statusError(DOWN_TEXT, 0)) // zuerst ablehnen, dann abbrechen: das Abbruch-Ergebnis von fn soll nicht gewinnen
|
||||||
|
controller.abort()
|
||||||
|
}, timeoutMs)
|
||||||
|
})
|
||||||
|
return Promise.race([fn(controller.signal), expired]).finally(() => clearTimeout(timer))
|
||||||
|
}
|
||||||
|
|
||||||
// fetch wirft (Netz, Dienst aus) ⇒ DOWN_TEXT mit status 0
|
// fetch wirft (Netz, Dienst aus) ⇒ DOWN_TEXT mit status 0
|
||||||
async function send(path, method, body, jwt) {
|
async function send(path, method, body, jwt, signal) {
|
||||||
const headers = { 'X-Appwrite-JWT': jwt }
|
const headers = { 'X-Appwrite-JWT': jwt }
|
||||||
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
||||||
try {
|
try {
|
||||||
return await fetchImpl(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) })
|
return await fetchImpl(`${BASE}${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), signal })
|
||||||
} catch {
|
} catch {
|
||||||
throw statusError(DOWN_TEXT, 0)
|
throw statusError(DOWN_TEXT, 0)
|
||||||
}
|
}
|
||||||
@@ -66,13 +96,8 @@ export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.f
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request(path, { method = 'GET', body } = {}) {
|
// Antwort auswerten (kein 401): Daten oder Error mit deutscher Meldung
|
||||||
let res = await send(path, method, body, await jwtFor())
|
async function read(res) {
|
||||||
if (res.status === 401) {
|
|
||||||
// einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen
|
|
||||||
res = await send(path, method, body, await jwtFor({ fresh: true }))
|
|
||||||
if (res.status === 401) throw statusError(SESSION_TEXT, 401)
|
|
||||||
}
|
|
||||||
let data
|
let data
|
||||||
try {
|
try {
|
||||||
data = await res.json()
|
data = await res.json()
|
||||||
@@ -90,6 +115,36 @@ export function createWorkersApi({ getJwt, fetchImpl = (...args) => globalThis.f
|
|||||||
return data ?? {}
|
return data ?? {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ein Versuch mit Zeitgrenze: JWT holen, senden, auswerten. { unauthorized: true } bei 401, sonst { data }.
|
||||||
|
function attempt(path, method, body, options) {
|
||||||
|
return withDeadline(async (signal) => {
|
||||||
|
const res = await send(path, method, body, await jwtFor(options), signal)
|
||||||
|
if (res.status === 401) return { unauthorized: true }
|
||||||
|
freshBlockedUntil = 0 // der Dienst hat das JWT angenommen: die nächste 401-Folge darf wieder einmal erneuern
|
||||||
|
return { data: await read(res) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(path, { method = 'GET', body } = {}) {
|
||||||
|
let out = await attempt(path, method, body)
|
||||||
|
if (out.unauthorized) {
|
||||||
|
// Während der Sperre gilt die 401 sofort als abgelaufene Sitzung (kein createJWT, keine zweite Anfrage)
|
||||||
|
if (now() < freshBlockedUntil) throw statusError(SESSION_TEXT, 401)
|
||||||
|
// sonst einmal mit frischem JWT wiederholen, danach gilt die Sitzung als abgelaufen und die Sperre beginnt
|
||||||
|
try {
|
||||||
|
out = await attempt(path, method, body, { fresh: true })
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.status === 401) freshBlockedUntil = now() + authCooldownMs // auch das frische JWT ließ sich nicht erzeugen
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
if (out.unauthorized) {
|
||||||
|
freshBlockedUntil = now() + authCooldownMs
|
||||||
|
throw statusError(SESSION_TEXT, 401)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out.data
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
state: () => request('/state'),
|
state: () => request('/state'),
|
||||||
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),
|
events: (after, limit = EVENTS_LIMIT) => request(`/events${query([['after', after], ['limit', clampLimit(limit, EVENTS_LIMIT)]])}`),
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|||||||
import { loadModule } from './load.mjs'
|
import { loadModule } from './load.mjs'
|
||||||
|
|
||||||
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
// API-Client der Hustler-Seite: URLs, Methoden, Header, deutsche Fehlertexte, JWT-Wiederholung, Pult-Prüfungen
|
||||||
const { createWorkersApi } = await loadModule('src/lib/workersApi.js')
|
const { createWorkersApi, REQUEST_TIMEOUT_MS, AUTH_COOLDOWN_MS } = await loadModule('src/lib/workersApi.js')
|
||||||
|
|
||||||
const jwts = []
|
const jwts = []
|
||||||
const getJwt = async (o) => {
|
const getJwt = async (o) => {
|
||||||
@@ -144,3 +144,134 @@ test('limit wird geklemmt (Ereignisse 1..200, Spur 1..50)', async () => {
|
|||||||
'/api/workers/events?limit=150',
|
'/api/workers/events?limit=150',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ---- Endprüfung P2: Zeitgrenze je Abruf und Sperre für frische JWTs nach einer 401-Folge ----
|
||||||
|
|
||||||
|
const DOWN = 'Der Hustler-Dienst ist gerade nicht erreichbar.'
|
||||||
|
const SESSION = 'Anmeldung abgelaufen. Bitte neu anmelden.'
|
||||||
|
|
||||||
|
test('Zeitgrenze: ein hängender fetch endet mit der Dienst-Meldung (status 0), das Signal wird abgebrochen', async () => {
|
||||||
|
const signals = []
|
||||||
|
const hang = (url, init) => {
|
||||||
|
signals.push(init.signal)
|
||||||
|
return new Promise(() => {}) // beachtet das Signal nicht einmal: die Zeitgrenze muss trotzdem greifen
|
||||||
|
}
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: hang, timeoutMs: 30 })
|
||||||
|
const t0 = Date.now()
|
||||||
|
await assert.rejects(api.state(), { message: DOWN, status: 0 })
|
||||||
|
assert.ok(Date.now() - t0 < 2000)
|
||||||
|
assert.equal(signals.length, 1)
|
||||||
|
assert.equal(signals[0].aborted, true)
|
||||||
|
// jeder Aufruf hat seine eigene Zeitgrenze (hier: Schreibaufruf und Spur)
|
||||||
|
await assert.rejects(api.pause(true), { message: DOWN, status: 0 })
|
||||||
|
await assert.rejects(api.trace('kim'), { message: DOWN, status: 0 })
|
||||||
|
assert.equal(signals.length, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zeitgrenze: auch eine Antwort, deren Rumpf nie ankommt, und ein hängendes createJWT enden mit der Dienst-Meldung', async () => {
|
||||||
|
const stalledBody = async () => ({ ok: true, status: 200, json: () => new Promise(() => {}) })
|
||||||
|
await assert.rejects(createWorkersApi({ getJwt, fetchImpl: stalledBody, timeoutMs: 30 }).state(), { message: DOWN, status: 0 })
|
||||||
|
const hungJwt = () => new Promise(() => {})
|
||||||
|
const f = fakeFetch(okReplies(1))
|
||||||
|
await assert.rejects(createWorkersApi({ getJwt: hungJwt, fetchImpl: f, timeoutMs: 30 }).state(), { message: DOWN, status: 0 })
|
||||||
|
assert.equal(f.calls.length, 0) // ohne JWT ging nichts hinaus
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zeitgrenze: rechtzeitige Antworten bleiben unberührt, ein späterer Abruf klappt wieder (Schleife wird frei)', async () => {
|
||||||
|
let n = 0
|
||||||
|
const flaky = async () => {
|
||||||
|
n += 1
|
||||||
|
if (n === 1) return new Promise(() => {})
|
||||||
|
return { ok: true, status: 200, json: async () => ({ n }) }
|
||||||
|
}
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: flaky, timeoutMs: 40 })
|
||||||
|
await assert.rejects(api.state(), { message: DOWN, status: 0 })
|
||||||
|
assert.deepEqual(await api.state(), { n: 2 })
|
||||||
|
const quick = createWorkersApi({ getJwt, fetchImpl: fakeFetch(okReplies(1)) }) // Standard-Zeitgrenze 20 s: wird nach der Antwort aufgeräumt
|
||||||
|
assert.deepEqual(await quick.state(), {})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Zeitgrenze: Standardwerte 20 s und 2 min', () => {
|
||||||
|
assert.equal(REQUEST_TIMEOUT_MS, 20000)
|
||||||
|
assert.equal(AUTH_COOLDOWN_MS, 120000)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('401-Folge: danach kein frisches JWT mehr, bis die Sperre abgelaufen ist (höchstens ein createJWT je Sperre)', async () => {
|
||||||
|
jwts.length = 0
|
||||||
|
let t = 1_000_000
|
||||||
|
const calls = []
|
||||||
|
const always401 = async (url, init) => {
|
||||||
|
calls.push(init.headers['X-Appwrite-JWT'])
|
||||||
|
return { ok: false, status: 401, json: async () => ({}) }
|
||||||
|
}
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: always401, now: () => t, authCooldownMs: 60000 })
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
assert.deepEqual(jwts, [false, true])
|
||||||
|
assert.deepEqual(calls, ['jwt-alt', 'jwt-neu']) // Versuch und Wiederholung
|
||||||
|
|
||||||
|
// innerhalb der Sperre: je Abruf genau eine Anfrage mit dem gespeicherten JWT, nie ein frisches, die Meldung bleibt
|
||||||
|
for (let i = 0; i < 20; i += 1) {
|
||||||
|
t += 2900 // 20 Schleifendurchläufe innerhalb der Sperre von 60 s (Zustand, Ereignisse und Spur zusammen)
|
||||||
|
await assert.rejects(api.events(null), { message: SESSION, status: 401 })
|
||||||
|
}
|
||||||
|
assert.equal(jwts.filter((fresh) => fresh).length, 1)
|
||||||
|
assert.equal(calls.length, 2 + 20)
|
||||||
|
|
||||||
|
// nach Ablauf der Sperre wird wieder einmal erneuert, danach beginnt die nächste Sperre
|
||||||
|
t += 2100
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
assert.equal(jwts.filter((fresh) => fresh).length, 2)
|
||||||
|
t += 5000
|
||||||
|
await assert.rejects(api.trace('kim'), { message: SESSION, status: 401 })
|
||||||
|
assert.equal(jwts.filter((fresh) => fresh).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('401-Folge: Erfolg hebt die Sperre auf, ein einzelnes 401 danach wird wieder mit frischem JWT wiederholt', async () => {
|
||||||
|
jwts.length = 0
|
||||||
|
let t = 0
|
||||||
|
const replies = [
|
||||||
|
{ status: 401, body: {} }, { status: 401, body: {} }, // Folge ⇒ Sperre
|
||||||
|
{ status: 200, body: { ok: 1 } }, // Dienst nimmt das gespeicherte JWT wieder an ⇒ Sperre weg
|
||||||
|
{ status: 401, body: {} }, { status: 200, body: { ok: 2 } }, // einzelnes 401 ⇒ Wiederholung mit frischem JWT hilft
|
||||||
|
]
|
||||||
|
const api = createWorkersApi({ getJwt, fetchImpl: fakeFetch(replies), now: () => t })
|
||||||
|
await assert.rejects(api.state(), { status: 401 })
|
||||||
|
t += 5000
|
||||||
|
assert.deepEqual(await api.state(), { ok: 1 })
|
||||||
|
assert.deepEqual(await api.state(), { ok: 2 })
|
||||||
|
assert.deepEqual(jwts, [false, true, false, false, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('401-Folge: scheitert schon die Erzeugung des frischen JWT, beginnt dieselbe Sperre', async () => {
|
||||||
|
let t = 0
|
||||||
|
const mints = []
|
||||||
|
const brokenFresh = async (o) => {
|
||||||
|
mints.push(o?.fresh === true)
|
||||||
|
if (o?.fresh) throw new Error('429 Too Many Requests')
|
||||||
|
return 'jwt-alt'
|
||||||
|
}
|
||||||
|
const api = createWorkersApi({ getJwt: brokenFresh, fetchImpl: fakeFetch(Array.from({ length: 6 }, () => ({ status: 401, body: {} }))), now: () => t, authCooldownMs: 60000 })
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
t += 10000
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
t += 10000
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
assert.deepEqual(mints, [false, true, false, false]) // ein einziger Versuch, ein frisches JWT zu erzeugen
|
||||||
|
t += 60000
|
||||||
|
await assert.rejects(api.state(), { message: SESSION, status: 401 })
|
||||||
|
assert.deepEqual(mints, [false, true, false, false, false, true])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('401-Folge: andere Fehler (403, 500, 502) heben die Sperre auf, weil der Dienst das JWT angenommen hat', async () => {
|
||||||
|
jwts.length = 0
|
||||||
|
let t = 0
|
||||||
|
const api = createWorkersApi({
|
||||||
|
getJwt,
|
||||||
|
fetchImpl: fakeFetch([{ status: 401, body: {} }, { status: 401, body: {} }, { status: 403, body: {} }, { status: 401, body: {} }, { status: 200, body: {} }]),
|
||||||
|
now: () => t,
|
||||||
|
})
|
||||||
|
await assert.rejects(api.state(), { status: 401 })
|
||||||
|
await assert.rejects(api.state(), { status: 403 })
|
||||||
|
assert.deepEqual(await api.state(), {}) // 401, Wiederholung frisch ⇒ 200
|
||||||
|
assert.deepEqual(jwts, [false, true, false, false, true])
|
||||||
|
})
|
||||||
@@ -6,6 +6,7 @@ import { loadModule } from './load.mjs'
|
|||||||
// Feed-Steuerung der Hustler-Seite: Zustand 15 s, Ereignisse 5 s, Cursor, Seiten, Sichtbarkeit, Fehler.
|
// Feed-Steuerung der Hustler-Seite: Zustand 15 s, Ereignisse 5 s, Cursor, Seiten, Sichtbarkeit, Fehler.
|
||||||
// Alles mit Fake-Timern und einer Fake-API (kein Netz, keine echte Zeit).
|
// Alles mit Fake-Timern und einer Fake-API (kein Netz, keine echte Zeit).
|
||||||
const { createFeedController } = await loadModule('src/lib/workersFeed.js')
|
const { createFeedController } = await loadModule('src/lib/workersFeed.js')
|
||||||
|
const { createWorkersApi } = await loadModule('src/lib/workersApi.js')
|
||||||
|
|
||||||
const nextTick = () => new Promise((resolve) => setImmediate(resolve))
|
const nextTick = () => new Promise((resolve) => setImmediate(resolve))
|
||||||
|
|
||||||
@@ -396,3 +397,39 @@ test('200 Ereignisse mit demselben ts halten den Feed nicht an (Fremd-API ohne e
|
|||||||
assert.equal(new Set(all).size, all.length)
|
assert.equal(new Set(all).size, all.length)
|
||||||
assert.deepEqual(h.log.errors, [])
|
assert.deepEqual(h.log.errors, [])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('hängender Abruf (tote Verbindung): die Zeitgrenze des API-Clients gibt die Schleife frei, Fehler erscheint und verschwindet wieder (Endprüfung P2)', async () => {
|
||||||
|
// Echter createWorkersApi mit kurzer Zeitgrenze, echte Timer mit kurzen Takten: der erste Zustands-Abruf kommt nie an
|
||||||
|
const state = { lastEventTs: '2026-10-17T20:00:00.000Z', workers: [] }
|
||||||
|
const urls = []
|
||||||
|
let hungOnce = false
|
||||||
|
const fetchImpl = async (url) => {
|
||||||
|
urls.push(url)
|
||||||
|
if (url.endsWith('/state') && !hungOnce) {
|
||||||
|
hungOnce = true
|
||||||
|
return new Promise(() => {})
|
||||||
|
}
|
||||||
|
return { ok: true, status: 200, json: async () => (url.endsWith('/state') ? state : { events: [] }) }
|
||||||
|
}
|
||||||
|
const api = createWorkersApi({ getJwt: async () => 'jwt', fetchImpl, timeoutMs: 40 })
|
||||||
|
const snapshots = []
|
||||||
|
const errors = []
|
||||||
|
const c = createFeedController({
|
||||||
|
api,
|
||||||
|
setTimer: (fn, ms) => setTimeout(fn, ms),
|
||||||
|
clearTimer: (id) => clearTimeout(id),
|
||||||
|
isVisible: () => true,
|
||||||
|
onSnapshot: (x) => snapshots.push(x),
|
||||||
|
onError: (e) => errors.push(e ? e.message : null),
|
||||||
|
stateMs: 20,
|
||||||
|
eventsMs: 20,
|
||||||
|
})
|
||||||
|
c.start()
|
||||||
|
const until = async (ok) => {
|
||||||
|
for (let i = 0; i < 100 && !ok(); i += 1) await new Promise((r) => setTimeout(r, 20))
|
||||||
|
}
|
||||||
|
await until(() => snapshots.length > 0)
|
||||||
|
c.stop()
|
||||||
|
assert.ok(snapshots.length >= 1, 'nach der Zeitgrenze kommt der Zustand doch noch')
|
||||||
|
assert.deepEqual(errors, ['Der Hustler-Dienst ist gerade nicht erreichbar.', null])
|
||||||
|
})
|
||||||
Reference in new issue
Block a user