Compare commits
17 commits
main
...
cron-minut
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051c1e88fb | ||
|
|
81f5602ce4 | ||
|
|
5675ebdf52 | ||
|
|
fd815282c2 | ||
|
|
3a7d0d110a | ||
|
|
8ebee878cb | ||
|
|
395b192432 | ||
|
|
dd90386fb6 | ||
|
|
ce59c15819 | ||
|
|
db490f5c99 | ||
|
|
bb1a7982dc | ||
|
|
29111e117c | ||
|
|
2644310bfa | ||
|
|
082b442f16 | ||
|
|
0eaf2a9895 | ||
|
|
cdabec2a92 | ||
|
|
291f92c37e |
8 changed files with 31 additions and 244 deletions
23
README.md
23
README.md
|
|
@ -51,30 +51,15 @@ Flotilla ──HTTP──▶ Mailship (PUT /subscription/email)
|
||||||
### PUT /subscription/email
|
### PUT /subscription/email
|
||||||
Idempotently register or update an email subscription. Re-sends the confirmation
|
Idempotently register or update an email subscription. Re-sends the confirmation
|
||||||
email only when the subscription is new or the email address changed; a frequency
|
email only when the subscription is new or the email address changed; a frequency
|
||||||
or schedule change keeps the existing confirmation. The pubkey is extracted from
|
change keeps the existing confirmation. The pubkey is extracted from the NIP-98
|
||||||
the NIP-98 Authorization header — the body does not include a `pubkey` field.
|
Authorization header — the body does not include a `pubkey` field.
|
||||||
|
|
||||||
```
|
```
|
||||||
Body: { email, frequency, hour?, minute?, dayOfWeek?, timezone? }
|
Body: { email, frequency }
|
||||||
Auth: NIP-98 (Nostr <base64> Authorization header)
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
||||||
Response: { key, callback }
|
Response: { key, callback }
|
||||||
```
|
```
|
||||||
|
|
||||||
**Optional schedule fields (defaults: `hour=17`, `minute=0`, `timezone="UTC"`):**
|
|
||||||
|
|
||||||
| Field | Type | Constraints | Default |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `hour` | integer | 0–23 | `17` |
|
|
||||||
| `minute` | integer | 0–59 | `0` |
|
|
||||||
| `dayOfWeek` | integer | 1=Mon … 7=Sun (0 also accepted as Sun); only valid when `frequency="weekly"` | `1` (Monday) for weekly, omitted for daily |
|
|
||||||
| `timezone` | string | IANA timezone name, e.g. `"America/New_York"` | `"UTC"` |
|
|
||||||
|
|
||||||
When omitted, each field falls back to its default. `dayOfWeek` is silently
|
|
||||||
ignored for daily frequency (the stored value is `NULL`).
|
|
||||||
|
|
||||||
**DOW convention:** `dayOfWeek` follows cron: 1=Monday, 2=Tuesday, …, 7=Sunday.
|
|
||||||
`0` is also accepted as Sunday (standard cron alias).
|
|
||||||
|
|
||||||
### GET /subscription/email
|
### GET /subscription/email
|
||||||
Look up an existing subscription for the authenticated pubkey, so clients can
|
Look up an existing subscription for the authenticated pubkey, so clients can
|
||||||
avoid re-registering (and re-confirming) when settings haven't changed.
|
avoid re-registering (and re-confirming) when settings haven't changed.
|
||||||
|
|
@ -82,7 +67,7 @@ Returns 404 if none exists.
|
||||||
|
|
||||||
```
|
```
|
||||||
Auth: NIP-98 (Nostr <base64> Authorization header)
|
Auth: NIP-98 (Nostr <base64> Authorization header)
|
||||||
Response: { key, callback, email, frequency, hour, minute, dayOfWeek, timezone, confirmed }
|
Response: { key, callback, email, frequency, confirmed }
|
||||||
```
|
```
|
||||||
|
|
||||||
### DELETE /subscription/:key
|
### DELETE /subscription/:key
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,6 @@ export type RegisterSubscriptionParams = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
email: string
|
email: string
|
||||||
frequency: string
|
frequency: string
|
||||||
hour?: number
|
|
||||||
minute?: number
|
|
||||||
dayOfWeek?: number
|
|
||||||
timezone?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Floor on how often a confirmation email can be sent for the same subscription.
|
// Floor on how often a confirmation email can be sent for the same subscription.
|
||||||
|
|
@ -26,8 +22,8 @@ export const CONFIRM_EMAIL_COOLDOWN_SECONDS = 10 * 60
|
||||||
|
|
||||||
export const registerSubscription = instrument(
|
export const registerSubscription = instrument(
|
||||||
'actions.registerSubscription',
|
'actions.registerSubscription',
|
||||||
async ({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone }: RegisterSubscriptionParams) => {
|
async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => {
|
||||||
const sub = await db.insertSubscription(pubkey, email, frequency, hour, minute, dayOfWeek, timezone)
|
const sub = await db.insertSubscription(pubkey, email, frequency)
|
||||||
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
const callback = `${process.env.BASE_URL}/notify/${sub.id}`
|
||||||
|
|
||||||
if (!sub.confirmed_at) {
|
if (!sub.confirmed_at) {
|
||||||
|
|
|
||||||
44
src/alert.ts
44
src/alert.ts
|
|
@ -4,10 +4,6 @@ export type Subscription = {
|
||||||
pubkey: string
|
pubkey: string
|
||||||
email: string
|
email: string
|
||||||
frequency: string
|
frequency: string
|
||||||
hour: number
|
|
||||||
minute: number
|
|
||||||
day_of_week?: number
|
|
||||||
timezone: string
|
|
||||||
created_at: number
|
created_at: number
|
||||||
confirmed_at?: number
|
confirmed_at?: number
|
||||||
unsubscribed_at?: number
|
unsubscribed_at?: number
|
||||||
|
|
@ -23,40 +19,14 @@ export const getSubscriptionError = (sub: Subscription) => {
|
||||||
if (!['daily', 'weekly'].includes(sub.frequency)) {
|
if (!['daily', 'weekly'].includes(sub.frequency)) {
|
||||||
return 'Frequency must be "daily" or "weekly"'
|
return 'Frequency must be "daily" or "weekly"'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sub.hour < 0 || sub.hour > 23 || !Number.isInteger(sub.hour)) {
|
|
||||||
return 'Hour must be an integer between 0 and 23'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub.minute < 0 || sub.minute > 59 || !Number.isInteger(sub.minute)) {
|
|
||||||
return 'Minute must be an integer between 0 and 59'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sub.frequency === 'weekly') {
|
|
||||||
if (sub.day_of_week === undefined || sub.day_of_week === null) {
|
|
||||||
return 'dayOfWeek is required for weekly frequency'
|
|
||||||
}
|
|
||||||
const dow = sub.day_of_week
|
|
||||||
if (dow < 0 || dow > 7 || !Number.isInteger(dow)) {
|
|
||||||
return 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sub.timezone || typeof sub.timezone !== 'string') {
|
|
||||||
return 'A valid IANA timezone is required'
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
Intl.DateTimeFormat(undefined, { timeZone: sub.timezone })
|
|
||||||
} catch {
|
|
||||||
return `"${sub.timezone}" is not a valid IANA timezone`
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCronExpression = (frequency: string, hour = 17, minute = 0, dayOfWeek?: number) => {
|
export const getCronExpression = (frequency: string, hour = 17, minute = 0) => {
|
||||||
if (frequency === 'daily') {
|
// Daily: fire at 17:00 UTC. Weekly: fire Monday (day-of-week 1) at 17:00 UTC.
|
||||||
|
// The `cron` package uses 6-field syntax (leading field is seconds).
|
||||||
|
if (frequency === 'weekly') {
|
||||||
|
return `0 ${minute} ${hour} * * 1`
|
||||||
|
}
|
||||||
|
|
||||||
return `0 ${minute} ${hour} * * *`
|
return `0 ${minute} ${hour} * * *`
|
||||||
}
|
|
||||||
// Weekly: default to Monday (1) when not specified
|
|
||||||
return `0 ${minute} ${hour} * * ${dayOfWeek ?? 1}`
|
|
||||||
}
|
}
|
||||||
|
|
@ -9,7 +9,7 @@ import type { Subscription } from './alert.js'
|
||||||
const DATA_DIR = process.env.DATA_DIR || '.'
|
const DATA_DIR = process.env.DATA_DIR || '.'
|
||||||
const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
|
const db = new sqlite3.Database(DATA_DIR + '/mailship.db')
|
||||||
|
|
||||||
type Param = number | string | boolean | null | undefined
|
type Param = number | string | boolean
|
||||||
|
|
||||||
type Row = Record<string, any>
|
type Row = Record<string, any>
|
||||||
|
|
||||||
|
|
@ -58,10 +58,6 @@ export const migrate = () =>
|
||||||
pubkey TEXT NOT NULL,
|
pubkey TEXT NOT NULL,
|
||||||
email TEXT NOT NULL,
|
email TEXT NOT NULL,
|
||||||
frequency TEXT NOT NULL DEFAULT 'daily',
|
frequency TEXT NOT NULL DEFAULT 'daily',
|
||||||
hour INTEGER NOT NULL DEFAULT 17,
|
|
||||||
minute INTEGER NOT NULL DEFAULT 0,
|
|
||||||
day_of_week INTEGER,
|
|
||||||
timezone TEXT NOT NULL DEFAULT 'UTC',
|
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
confirmed_at INTEGER,
|
confirmed_at INTEGER,
|
||||||
unsubscribed_at INTEGER,
|
unsubscribed_at INTEGER,
|
||||||
|
|
@ -121,20 +117,6 @@ export const migrate = () =>
|
||||||
WHERE unsubscribed_at IS NULL
|
WHERE unsubscribed_at IS NULL
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Idempotent migration: add schedule columns to existing databases.
|
|
||||||
// ALTER TABLE ADD COLUMN fails if the column already exists, so we
|
|
||||||
// run each one and ignore "duplicate column" errors.
|
|
||||||
const addCol = (colDef: string) =>
|
|
||||||
run(`ALTER TABLE subscriptions ADD COLUMN ${colDef}`).catch((err: any) => {
|
|
||||||
if (!err?.message?.includes('duplicate column')) throw err
|
|
||||||
})
|
|
||||||
|
|
||||||
await addCol('hour INTEGER NOT NULL DEFAULT 17')
|
|
||||||
await addCol('minute INTEGER NOT NULL DEFAULT 0')
|
|
||||||
await addCol('day_of_week INTEGER')
|
|
||||||
await addCol("timezone TEXT NOT NULL DEFAULT 'UTC'")
|
|
||||||
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -152,30 +134,19 @@ const parseSubscription = (row: any): Subscription | undefined => {
|
||||||
|
|
||||||
export const updateSubscription = instrument(
|
export const updateSubscription = instrument(
|
||||||
'database.updateSubscription',
|
'database.updateSubscription',
|
||||||
async (existing: Subscription, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
|
async (existing: Subscription, email: string, frequency: string) => {
|
||||||
const scheduleChanged =
|
if (existing.email === email && existing.frequency === frequency) {
|
||||||
(hour !== undefined && hour !== existing.hour) ||
|
|
||||||
(minute !== undefined && minute !== existing.minute) ||
|
|
||||||
(dayOfWeek !== undefined && dayOfWeek !== existing.day_of_week) ||
|
|
||||||
(timezone !== undefined && timezone !== existing.timezone)
|
|
||||||
|
|
||||||
if (existing.email === email && existing.frequency === frequency && !scheduleChanged) {
|
|
||||||
return existing
|
return existing
|
||||||
}
|
}
|
||||||
|
|
||||||
const hr = hour ?? existing.hour
|
|
||||||
const mn = minute ?? existing.minute
|
|
||||||
const dow = dayOfWeek ?? existing.day_of_week
|
|
||||||
const tz = timezone ?? existing.timezone
|
|
||||||
|
|
||||||
// Update by id, not pubkey, so tombstoned rows for the same account are never
|
// Update by id, not pubkey, so tombstoned rows for the same account are never
|
||||||
// re-activated alongside this one (the unique index would reject that anyway).
|
// re-activated alongside this one (the unique index would reject that anyway).
|
||||||
if (existing.email === email) {
|
if (existing.email === email) {
|
||||||
return parseSubscription(
|
return parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`UPDATE subscriptions SET frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, unsubscribed_at = NULL
|
`UPDATE subscriptions SET frequency = ?, unsubscribed_at = NULL
|
||||||
WHERE id = ? RETURNING *`,
|
WHERE id = ? RETURNING *`,
|
||||||
[frequency, hr, mn, dow, tz, existing.id]
|
[frequency, existing.id]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -184,9 +155,9 @@ export const updateSubscription = instrument(
|
||||||
// cooldown or the new address would inherit the old one's lockout.
|
// cooldown or the new address would inherit the old one's lockout.
|
||||||
return parseSubscription(
|
return parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`UPDATE subscriptions SET email = ?, frequency = ?, hour = ?, minute = ?, day_of_week = ?, timezone = ?, confirmed_at = NULL, unsubscribed_at = NULL, last_confirm_sent_at = NULL
|
`UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL, last_confirm_sent_at = NULL
|
||||||
WHERE id = ? RETURNING *`,
|
WHERE id = ? RETURNING *`,
|
||||||
[email, frequency, hr, mn, dow, tz, existing.id]
|
[email, frequency, existing.id]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -223,11 +194,11 @@ const reactivateSubscription = async (tombstoned: Subscription, frequency: strin
|
||||||
|
|
||||||
export const insertSubscription = instrument(
|
export const insertSubscription = instrument(
|
||||||
'database.insertSubscription',
|
'database.insertSubscription',
|
||||||
async (pubkey: string, email: string, frequency: string, hour?: number, minute?: number, dayOfWeek?: number, timezone?: string) => {
|
async (pubkey: string, email: string, frequency: string) => {
|
||||||
const existing = await getSubscriptionByPubkey(pubkey)
|
const existing = await getSubscriptionByPubkey(pubkey)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return assertResult(await updateSubscription(existing, email, frequency, hour, minute, dayOfWeek, timezone))
|
return assertResult(await updateSubscription(existing, email, frequency))
|
||||||
}
|
}
|
||||||
|
|
||||||
// No active row. If this account previously subscribed to this email and
|
// No active row. If this account previously subscribed to this email and
|
||||||
|
|
@ -256,18 +227,14 @@ export const insertSubscription = instrument(
|
||||||
return assertResult(
|
return assertResult(
|
||||||
parseSubscription(
|
parseSubscription(
|
||||||
await get(
|
await get(
|
||||||
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, hour, minute, day_of_week, timezone, created_at)
|
`INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING *`,
|
||||||
[
|
[
|
||||||
crypto.randomUUID(),
|
crypto.randomUUID(),
|
||||||
crypto.randomBytes(32).toString('hex'),
|
crypto.randomBytes(32).toString('hex'),
|
||||||
pubkey,
|
pubkey,
|
||||||
email,
|
email,
|
||||||
frequency,
|
frequency,
|
||||||
hour ?? 17,
|
|
||||||
minute ?? 0,
|
|
||||||
dayOfWeek ?? null,
|
|
||||||
timezone ?? 'UTC',
|
|
||||||
now(),
|
now(),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
@ -281,7 +248,7 @@ export const insertSubscription = instrument(
|
||||||
const concurrent = await getSubscriptionByPubkey(pubkey)
|
const concurrent = await getSubscriptionByPubkey(pubkey)
|
||||||
|
|
||||||
if (concurrent) {
|
if (concurrent) {
|
||||||
return assertResult(await updateSubscription(concurrent, email, frequency, hour, minute, dayOfWeek, timezone))
|
return assertResult(await updateSubscription(concurrent, email, frequency))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -186,10 +186,6 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
callback,
|
callback,
|
||||||
email: sub.email,
|
email: sub.email,
|
||||||
frequency: sub.frequency,
|
frequency: sub.frequency,
|
||||||
hour: sub.hour,
|
|
||||||
minute: sub.minute,
|
|
||||||
dayOfWeek: sub.day_of_week,
|
|
||||||
timezone: sub.timezone,
|
|
||||||
confirmed: Boolean(sub.confirmed_at),
|
confirmed: Boolean(sub.confirmed_at),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -198,7 +194,7 @@ addRoute('get', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
// auth proving the caller controls the pubkey — the pubkey is extracted from
|
// auth proving the caller controls the pubkey — the pubkey is extracted from
|
||||||
// the auth event, not from the request body.
|
// the auth event, not from the request body.
|
||||||
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
const { email, frequency, hour, minute, dayOfWeek, timezone } = req.body
|
const { email, frequency } = req.body
|
||||||
|
|
||||||
const pubkey = await verifyNip98Auth(req)
|
const pubkey = await verifyNip98Auth(req)
|
||||||
|
|
||||||
|
|
@ -214,38 +210,8 @@ addRoute('put', '/subscription/email', async (req: Request, res: Response) => {
|
||||||
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
|
return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate optional schedule fields
|
|
||||||
if (hour !== undefined) {
|
|
||||||
if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
|
|
||||||
return res.status(400).json({ error: 'Hour must be an integer between 0 and 23' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (minute !== undefined) {
|
|
||||||
if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
|
|
||||||
return res.status(400).json({ error: 'Minute must be an integer between 0 and 59' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dayOfWeek !== undefined) {
|
|
||||||
if (frequency !== 'weekly') {
|
|
||||||
return res.status(400).json({ error: 'dayOfWeek is only valid for weekly frequency' })
|
|
||||||
}
|
|
||||||
if (!Number.isInteger(dayOfWeek) || dayOfWeek < 0 || dayOfWeek > 7) {
|
|
||||||
return res.status(400).json({ error: 'dayOfWeek must be an integer between 0 and 7 (1=Mon, 7=Sun, 0=Sun)' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (timezone !== undefined) {
|
|
||||||
try {
|
try {
|
||||||
Intl.DateTimeFormat(undefined, { timeZone: timezone })
|
const result = await registerSubscription({ pubkey, email, frequency })
|
||||||
} catch {
|
|
||||||
return res.status(400).json({ error: `"${timezone}" is not a valid IANA timezone` })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await registerSubscription({ pubkey, email, frequency, hour, minute, dayOfWeek, timezone })
|
|
||||||
res.json(result)
|
res.json(result)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
// The subscription was still created, but sending the confirmation email
|
// The subscription was still created, but sending the confirmation email
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ export const runJob = async (sub: Subscription) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
const createJob = (sub: Subscription) => {
|
const createJob = (sub: Subscription) => {
|
||||||
const cron = getCronExpression(sub.frequency, sub.hour, sub.minute, sub.day_of_week)
|
const cron = getCronExpression(sub.frequency)
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
// Re-fetch the subscription to pick up the latest last_digest_at.
|
// Re-fetch the subscription to pick up the latest last_digest_at.
|
||||||
|
|
@ -64,7 +64,7 @@ const createJob = (sub: Subscription) => {
|
||||||
cronTime: cron,
|
cronTime: cron,
|
||||||
onTick: run,
|
onTick: run,
|
||||||
start: true,
|
start: true,
|
||||||
timeZone: sub.timezone ?? 'UTC',
|
timeZone: 'UTC',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
|
||||||
import * as db from '../src/database.js'
|
|
||||||
import { registerSubscription } from '../src/actions.js'
|
|
||||||
import { getCronExpression } from '../src/alert.js'
|
|
||||||
import { getJobCronSource, removeJob } from '../src/worker/email.js'
|
|
||||||
import { registerSubscription as regSub } from '../src/worker/index.js'
|
|
||||||
|
|
||||||
const pubkey = 'schedule-test-' + Date.now()
|
|
||||||
const email = 'schedule-test-' + Date.now() + '@example.com'
|
|
||||||
let sub: any = null
|
|
||||||
|
|
||||||
describe('Schedule fields', () => {
|
|
||||||
beforeAll(async () => {
|
|
||||||
await db.migrate()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts subscription with custom hour/minute/timezone', async () => {
|
|
||||||
const s = await db.insertSubscription(pubkey, email, 'daily', 7, 30, undefined, 'America/New_York')
|
|
||||||
expect(s).toBeTruthy()
|
|
||||||
expect(s!.hour).toBe(7)
|
|
||||||
expect(s!.minute).toBe(30)
|
|
||||||
expect(s!.timezone).toBe('America/New_York')
|
|
||||||
expect(s!.day_of_week).toBeNull()
|
|
||||||
sub = s
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts subscription with custom weekly dayOfWeek', async () => {
|
|
||||||
const pk2 = 'schedule-test-weekly-' + Date.now()
|
|
||||||
const em2 = pk2 + '@example.com'
|
|
||||||
const s = await db.insertSubscription(pk2, em2, 'weekly', 9, 0, 6, 'UTC')
|
|
||||||
expect(s).toBeTruthy()
|
|
||||||
expect(s!.hour).toBe(9)
|
|
||||||
expect(s!.minute).toBe(0)
|
|
||||||
expect(s!.day_of_week).toBe(6)
|
|
||||||
expect(s!.timezone).toBe('UTC')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('inserts subscription with defaults when schedule omitted', async () => {
|
|
||||||
const pk3 = 'schedule-test-defaults-' + Date.now()
|
|
||||||
const em3 = pk3 + '@example.com'
|
|
||||||
const s = await db.insertSubscription(pk3, em3, 'daily')
|
|
||||||
expect(s).toBeTruthy()
|
|
||||||
expect(s!.hour).toBe(17)
|
|
||||||
expect(s!.minute).toBe(0)
|
|
||||||
expect(s!.timezone).toBe('UTC')
|
|
||||||
expect(s!.day_of_week).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getCronExpression returns daily with custom hour/minute', () => {
|
|
||||||
expect(getCronExpression('daily', 7, 30)).toBe('0 30 7 * * *')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getCronExpression returns weekly with custom dayOfWeek', () => {
|
|
||||||
expect(getCronExpression('weekly', 9, 0, 6)).toBe('0 0 9 * * 6')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getCronExpression defaults to Monday for weekly', () => {
|
|
||||||
expect(getCronExpression('weekly', 17, 0)).toBe('0 0 17 * * 1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('confirms and registers job with custom schedule', async () => {
|
|
||||||
// Confirm the daily subscription created above
|
|
||||||
const confirmed = await db.confirmSubscription(sub.key)
|
|
||||||
expect(confirmed).toBeTruthy()
|
|
||||||
sub = confirmed!.sub
|
|
||||||
|
|
||||||
regSub(sub)
|
|
||||||
const dailySource = getJobCronSource(sub.id)
|
|
||||||
// Expect 0 30 7 * * * (from custom hour=7, minute=30)
|
|
||||||
expect(dailySource).toBe('0 30 7 * * *')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updateSubscription preserves schedule fields through frequency change', async () => {
|
|
||||||
const updated = await db.updateSubscription(sub, email, 'weekly', undefined, undefined, 2, undefined)
|
|
||||||
expect(updated).toBeTruthy()
|
|
||||||
expect(updated!.frequency).toBe('weekly')
|
|
||||||
// hour/minute should keep the previously set values (7/30) since we passed undefined
|
|
||||||
expect(updated!.hour).toBe(7)
|
|
||||||
expect(updated!.minute).toBe(30)
|
|
||||||
expect(updated!.day_of_week).toBe(2)
|
|
||||||
expect(updated!.timezone).toBe('America/New_York')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('registerSubscription preserves schedule fields', async () => {
|
|
||||||
await registerSubscription({ pubkey, email, frequency: 'daily', hour: 10, minute: 15, timezone: 'Europe/London' })
|
|
||||||
const reloaded = await db.getSubscriptionByPubkey(pubkey)
|
|
||||||
expect(reloaded).toBeTruthy()
|
|
||||||
expect(reloaded!.hour).toBe(10)
|
|
||||||
expect(reloaded!.minute).toBe(15)
|
|
||||||
expect(reloaded!.timezone).toBe('Europe/London')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
const updated = await db.getSubscriptionByPubkey(pubkey)
|
|
||||||
if (updated) removeJob(updated)
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue