Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
6de04c6
fix(api): return 409 for constraint conflicts and harden login lookup.
m-murphy Sep 25, 2026
4246f3b
fix(export): quote the chosen delimiter and neutralize formula cells.
m-murphy Sep 25, 2026
71c1919
fix(api): validate derivation CSV rows the way the import writes them.
m-murphy Sep 25, 2026
8541cc4
fix(api): parse quoted plate scanner CSVs.
m-murphy Sep 25, 2026
4d01f15
fix(stats): correct study, location, and date filters.
m-murphy Sep 25, 2026
e014408
fix(export): keep every listed visit and undated subjects.
m-murphy Sep 25, 2026
c00dd98
fix(api): guard qPCR targets, control definition edits, and activity …
m-murphy Sep 25, 2026
938057f
fix(scripts): make the integrity check read-only and complete.
m-murphy Sep 25, 2026
4d4ce51
fix(api): resolve sheets by box or bag and keep names unique per box.
m-murphy Sep 25, 2026
e3fdf2f
fix(api): set a location's storage type correctly on create and move.
m-murphy Sep 25, 2026
c532739
fix(controls): create multi-density batches all-or-nothing.
m-murphy Sep 25, 2026
af9f940
fix(web): sign out on an expired session and drop the cache on user c…
m-murphy Sep 25, 2026
5a3ceb1
fix(web): refresh after settings and qPCR edits; drop stale responses.
m-murphy Sep 25, 2026
dd18f03
fix(web): parse bulk import and scanner CSVs with quotes.
m-murphy Sep 25, 2026
c98a782
fix(web): show and default calendar dates in local time.
m-murphy Sep 25, 2026
e0047be
fix(containers): save a cleared container comment.
m-murphy Sep 25, 2026
0002518
fix(web): keep a scanner Enter from submitting the collection entry g…
m-murphy Sep 25, 2026
dd4ff42
fix(web): URL-driven specimen filters, Backspace in modals, large upl…
m-murphy Sep 25, 2026
e1372e4
fix(studies): query summary and delete with subqueries, not id lists.
m-murphy Sep 25, 2026
30ceb3e
fix(auth): take as long to reject a missing account as a wrong password.
m-murphy Sep 25, 2026
6b72c15
fix(search): match % and _ in search text literally.
m-murphy Sep 25, 2026
643f006
perf(activity): build the recent feed with joins instead of per-row q…
m-murphy Sep 25, 2026
c89f89e
fix(web): keep /studies search, sort, and filters in the URL.
m-murphy Sep 25, 2026
f9ceb98
test(web): cover cache clearing and session expiry in UserProvider.
m-murphy Sep 25, 2026
a3bddaa
fix(db): create the qPCR result tables on fresh installs.
m-murphy Sep 25, 2026
28c79d0
fix(export): spare only whole numbers from formula neutralization.
m-murphy Oct 6, 2026
e1c8527
fix(api): stop resolving sheets by name alone.
m-murphy Oct 6, 2026
f9c9378
fix(web): show the remaining calendar dates on their own day.
m-murphy Oct 6, 2026
55eea44
fix(web): let Back undo /studies sort and filter changes.
m-murphy Oct 6, 2026
5ae933e
fix(controls): keep wizard conflicts a 409 and say "control batch".
m-murphy Oct 6, 2026
9b37827
test(web): cover the qPCR well and Locations search races.
m-murphy Oct 6, 2026
92b0882
test(api): cover the integrity script's tag/qPCR checks and settings …
m-murphy Oct 6, 2026
538485e
test(web): write CollectionSpecimenEntry tests the way the test READM…
m-murphy Oct 6, 2026
ff7dd54
refactor: share the blank-row CSV filter and calendar-date shift.
m-murphy Oct 6, 2026
c03663f
refactor(api): reuse the composition finder and study specimen subquery.
m-murphy Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ packages/e2e/sampledb_e2e.sqlite
scripts/
!packages/api/scripts/
!packages/api/scripts/seed-table-view-config.ts
!packages/api/src/scripts/
!packages/docs/scripts/

test-csvs/
40 changes: 39 additions & 1 deletion packages/api/initial_schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ CREATE TABLE IF NOT EXISTS sheet (
(box_id IS NULL AND bag_id IS NOT NULL) OR
(box_id IS NULL AND bag_id IS NULL))
);--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS sheet_box_name_idx ON sheet(box_id, name) WHERE box_id IS NOT NULL;--> statement-breakpoint
CREATE TABLE IF NOT EXISTS paper (
id INTEGER PRIMARY KEY REFERENCES storage_container(id),
sheet_id INTEGER NOT NULL REFERENCES sheet(id),
Expand All @@ -283,7 +284,7 @@ CREATE TABLE IF NOT EXISTS storage_type (
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
);--> statement-breakpoint
INSERT INTO schema_version (version) VALUES (5);--> statement-breakpoint
INSERT INTO schema_version (version) VALUES (7);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS settings (
key TEXT NOT NULL,
user_id INTEGER REFERENCES users(id),
Expand Down Expand Up @@ -383,5 +384,42 @@ CREATE TABLE IF NOT EXISTS qpcr_experiment_well (
UNIQUE (qpcr_experiment_id, well_position)
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_experiment_well_experiment_idx ON qpcr_experiment_well(qpcr_experiment_id);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS qpcr_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_experiment_id INTEGER NOT NULL REFERENCES qpcr_experiment(id) ON DELETE CASCADE,
instrument_type TEXT NOT NULL,
run_started_at TEXT,
run_ended_at TEXT,
experiment_name TEXT,
file_name TEXT,
created TEXT NOT NULL DEFAULT current_timestamp,
slope REAL,
y_intercept REAL,
r_squared REAL,
efficiency REAL
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_run_experiment_idx ON qpcr_run(qpcr_experiment_id);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS qpcr_well_result (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_run_id INTEGER NOT NULL REFERENCES qpcr_run(id) ON DELETE CASCADE,
well_position TEXT NOT NULL,
target_name TEXT,
sample_barcode TEXT,
task TEXT,
cq REAL,
quantity REAL,
standard_quantity REAL,
amp_status TEXT,
UNIQUE (qpcr_run_id, well_position, target_name)
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_well_result_run_idx ON qpcr_well_result(qpcr_run_id);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS qpcr_amplification_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_well_result_id INTEGER NOT NULL REFERENCES qpcr_well_result(id) ON DELETE CASCADE,
cycle INTEGER NOT NULL,
rn REAL,
delta_rn REAL
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_amplification_data_well_result_idx ON qpcr_amplification_data(qpcr_well_result_id);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_experiment_well_specimen_idx ON qpcr_experiment_well(specimen_id);--> statement-breakpoint
PRAGMA foreign_keys=ON;
3 changes: 2 additions & 1 deletion packages/api/src/bun-sqlite.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
declare module 'bun:sqlite' {
export class Database {
constructor(path: string | ':memory:')
constructor(path: string | ':memory:', options?: { readonly?: boolean; create?: boolean; readwrite?: boolean; strict?: boolean })
readonly filename: string
exec(sql: string): void
prepare(sql: string): Statement
query(sql: string): Statement
Expand Down
1 change: 1 addition & 0 deletions packages/api/src/db/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ describe('Database Client', () => {
db.exec(`CREATE TABLE cryovial_tube (id INTEGER PRIMARY KEY, collection_id INTEGER NOT NULL, barcode TEXT, position TEXT)`)
db.exec(`CREATE TABLE static_well (id INTEGER PRIMARY KEY, collection_id INTEGER NOT NULL, position TEXT)`)
db.exec(`CREATE TABLE study_subject (id INTEGER PRIMARY KEY, study_id INTEGER NOT NULL, name TEXT NOT NULL)`)
db.exec(`CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT NOT NULL, box_id INTEGER, bag_id INTEGER)`)
// Intentionally omit paper — migration 003 preflight creates legacy stub
// Intentionally do NOT create error_logs
const before = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='error_logs'").get()
Expand Down
41 changes: 37 additions & 4 deletions packages/api/src/db/__tests__/schema-evolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ function createTablesForLaterMigrations(sqlite: Database): void {
study_id INTEGER NOT NULL,
name TEXT NOT NULL
)`)
sqlite.exec(`CREATE TABLE IF NOT EXISTS sheet (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
box_id INTEGER,
bag_id INTEGER
)`)
}

describe('schema evolution', () => {
Expand Down Expand Up @@ -98,7 +104,7 @@ describe('schema evolution', () => {
'CREATE TABLE settings (key TEXT, user_id INTEGER, value TEXT, PRIMARY KEY (key, user_id))',
)
sqlite.exec('CREATE TABLE storage_container (id INTEGER PRIMARY KEY)')
sqlite.exec('CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT)')
sqlite.exec('CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT, box_id INTEGER, bag_id INTEGER)')
sqlite.exec(`CREATE TABLE paper (
id INTEGER PRIMARY KEY REFERENCES storage_container(id),
sheet_id INTEGER NOT NULL REFERENCES sheet(id),
Expand All @@ -123,7 +129,7 @@ describe('schema evolution', () => {
sqlite.exec('INSERT INTO schema_version (version) VALUES (1)')
sqlite.exec('CREATE TABLE study (id INTEGER PRIMARY KEY)')
sqlite.exec('CREATE TABLE storage_container (id INTEGER PRIMARY KEY)')
sqlite.exec('CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT)')
sqlite.exec('CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT, box_id INTEGER, bag_id INTEGER)')
sqlite.exec(`CREATE TABLE paper (
id INTEGER PRIMARY KEY REFERENCES storage_container(id),
sheet_id INTEGER NOT NULL REFERENCES sheet(id),
Expand All @@ -141,7 +147,7 @@ describe('schema evolution', () => {
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
sqlite.exec('INSERT INTO schema_version (version) VALUES (2)')
sqlite.exec(`CREATE TABLE storage_container (id INTEGER PRIMARY KEY)`)
sqlite.exec(`CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT)`)
sqlite.exec(`CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT, box_id INTEGER, bag_id INTEGER)`)
sqlite.exec(`CREATE TABLE paper (
id INTEGER PRIMARY KEY REFERENCES storage_container(id),
sheet_id INTEGER NOT NULL REFERENCES sheet(id),
Expand Down Expand Up @@ -181,7 +187,7 @@ describe('schema evolution', () => {
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
sqlite.exec('INSERT INTO schema_version (version) VALUES (2)')
sqlite.exec(`CREATE TABLE storage_container (id INTEGER PRIMARY KEY)`)
sqlite.exec(`CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT)`)
sqlite.exec(`CREATE TABLE sheet (id INTEGER PRIMARY KEY, name TEXT, box_id INTEGER, bag_id INTEGER)`)
sqlite.exec(`CREATE TABLE paper (
id INTEGER PRIMARY KEY REFERENCES storage_container(id),
sheet_id INTEGER NOT NULL REFERENCES sheet(id),
Expand Down Expand Up @@ -260,6 +266,33 @@ describe('schema evolution', () => {
sqlite.close()
})

it('migration 006 makes sheet names unique per box but not per bag', () => {
const sqlite = new Database(testDbPath)
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
sqlite.exec('INSERT INTO schema_version (version) VALUES (5)')
createTablesForLaterMigrations(sqlite)
sqlite.exec(`INSERT INTO sheet (id, name, box_id, bag_id) VALUES (1, 'S1', 1, NULL), (2, 'S1', NULL, 7), (3, 'S1', NULL, 7)`)

evolveOperationalSchema(sqlite)

expect(getRecordedSchemaVersion(sqlite)).toBe(CURRENT_SCHEMA_VERSION)
expect(() => sqlite.exec(`INSERT INTO sheet (id, name, box_id) VALUES (4, 'S1', 1)`)).toThrow(/UNIQUE/)
sqlite.exec(`INSERT INTO sheet (id, name, bag_id) VALUES (5, 'S1', 7)`)
sqlite.close()
})

it('migration 006 aborts when a box has two sheets with the same name', () => {
const sqlite = new Database(testDbPath)
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
sqlite.exec('INSERT INTO schema_version (version) VALUES (5)')
createTablesForLaterMigrations(sqlite)
sqlite.exec(`INSERT INTO sheet (id, name, box_id) VALUES (1, 'S1', 1), (2, 'S1', 1)`)

expect(() => evolveOperationalSchema(sqlite)).toThrow(/Rename one of them first/)
expect(getRecordedSchemaVersion(sqlite)).toBe(5)
sqlite.close()
})

it('fail-hard: invalid migration leaves schema_version unchanged', () => {
const sqlite = new Database(testDbPath)
sqlite.exec('CREATE TABLE schema_version (version INTEGER NOT NULL)')
Expand Down
15 changes: 15 additions & 0 deletions packages/api/src/db/migrations/006_box_sheet_name_unique.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- Migration 006: sheet names are unique within a box. Bags are exempt: legacy DBS bags hold
-- many single-paper sheets that share one name. (UNIQUE(name, box_id, bag_id) never fired
-- because one of box_id / bag_id is always NULL.)
CREATE TEMP TABLE IF NOT EXISTS __migration_006_guard (id INTEGER PRIMARY KEY);--> statement-breakpoint
CREATE TRIGGER __migration_006_guard_tr BEFORE INSERT ON __migration_006_guard
BEGIN
SELECT RAISE(ABORT, 'Cannot add unique sheet name index: a box has two sheets with the same name. Rename one of them first.')
WHERE EXISTS (
SELECT 1 FROM sheet WHERE box_id IS NOT NULL GROUP BY box_id, name HAVING COUNT(*) > 1
);
END;--> statement-breakpoint
INSERT INTO __migration_006_guard DEFAULT VALUES;--> statement-breakpoint
DROP TRIGGER __migration_006_guard_tr;--> statement-breakpoint
DROP TABLE __migration_006_guard;--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS sheet_box_name_idx ON sheet(box_id, name) WHERE box_id IS NOT NULL;
40 changes: 40 additions & 0 deletions packages/api/src/db/migrations/007_qpcr_result_tables.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
-- Migration 007: qPCR result tables. They exist in databases created before the SQL snapshot
-- (matching definitions below) but initial_schema.sql never created them, so result uploads failed
-- on fresh installs. IF NOT EXISTS leaves existing databases unchanged.
CREATE TABLE IF NOT EXISTS qpcr_run (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_experiment_id INTEGER NOT NULL REFERENCES qpcr_experiment(id) ON DELETE CASCADE,
instrument_type TEXT NOT NULL,
run_started_at TEXT,
run_ended_at TEXT,
experiment_name TEXT,
file_name TEXT,
created TEXT NOT NULL DEFAULT current_timestamp,
slope REAL,
y_intercept REAL,
r_squared REAL,
efficiency REAL
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_run_experiment_idx ON qpcr_run(qpcr_experiment_id);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS qpcr_well_result (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_run_id INTEGER NOT NULL REFERENCES qpcr_run(id) ON DELETE CASCADE,
well_position TEXT NOT NULL,
target_name TEXT,
sample_barcode TEXT,
task TEXT,
cq REAL,
quantity REAL,
standard_quantity REAL,
amp_status TEXT,
UNIQUE (qpcr_run_id, well_position, target_name)
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_well_result_run_idx ON qpcr_well_result(qpcr_run_id);--> statement-breakpoint
CREATE TABLE IF NOT EXISTS qpcr_amplification_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
qpcr_well_result_id INTEGER NOT NULL REFERENCES qpcr_well_result(id) ON DELETE CASCADE,
cycle INTEGER NOT NULL,
rn REAL,
delta_rn REAL
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS qpcr_amplification_data_well_result_idx ON qpcr_amplification_data(qpcr_well_result_id);
17 changes: 8 additions & 9 deletions packages/api/src/db/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,13 @@ function resolveDatabaseFilePath(relativeOrAbsolute: string): string {
return isAbsolute(relativeOrAbsolute) ? relativeOrAbsolute : resolve(getMonorepoRoot(), relativeOrAbsolute)
}

/** Path openOperationalDatabase would use: explicit path, then DATABASE_PATH, then the dev default. */
export function resolveOperationalDatabasePath(dbPath?: string): string {
if (dbPath) return resolveDatabaseFilePath(dbPath)
if (process.env.DATABASE_PATH) return resolveDatabaseFilePath(process.env.DATABASE_PATH)
return resolve(getMonorepoRoot(), 'sampledb_dev.sqlite')
}

export type OperationalDatabase = ReturnType<typeof drizzle<typeof schema>>

/**
Expand All @@ -89,15 +96,7 @@ export function openOperationalDatabase(dbPath?: string): {
db: OperationalDatabase
sqlite: SQLiteDatabase
} {
let resolvedPath: string

if (dbPath) {
resolvedPath = resolveDatabaseFilePath(dbPath)
} else if (process.env.DATABASE_PATH) {
resolvedPath = resolveDatabaseFilePath(process.env.DATABASE_PATH)
} else {
resolvedPath = resolve(getMonorepoRoot(), 'sampledb_dev.sqlite')
}
const resolvedPath = resolveOperationalDatabasePath(dbPath)

if (process.env.NODE_ENV !== 'production') {
console.log(`📁 Monorepo root: ${getMonorepoRoot()}`)
Expand Down
2 changes: 1 addition & 1 deletion packages/api/src/db/schema-evolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { listNumberedMigrations, runSqlMigration } from './migration-runner'
const PAPER_SUBLABEL_MIGRATION_VERSION = 3

/** Canonical schema level; bump when adding numbered deltas under migrations/. */
export const CURRENT_SCHEMA_VERSION = 5
export const CURRENT_SCHEMA_VERSION = 7

const SCHEMA_VERSION_TABLE = 'schema_version'
const LEGACY_BASELINE_VERSION = 1
Expand Down
3 changes: 2 additions & 1 deletion packages/api/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,8 @@ export const sheet = sqliteTable('sheet', {
(${t.boxId} IS NULL AND ${t.bagId} IS NOT NULL) OR
(${t.boxId} IS NULL AND ${t.bagId} IS NULL)
`),
unq: unique().on(t.name, t.boxId, t.bagId)
unq: unique().on(t.name, t.boxId, t.bagId),
boxNameUniq: uniqueIndex('sheet_box_name_idx').on(t.boxId, t.name).where(sql`${t.boxId} IS NOT NULL`),
}))

export const paper = sqliteTable('paper', {
Expand Down
37 changes: 36 additions & 1 deletion packages/api/src/lib/__tests__/container-move.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
type ContainerInfo,
} from '../container-move'
import { storageContainer } from '../../db/schema'
import { micronixTube, staticWell } from '../../db/schema'
import { micronixTube, staticWell, box, sheet, paper } from '../../db/schema'
import type { Database } from '../../db/client'
import { utcNow } from '../datetime'

Expand Down Expand Up @@ -560,4 +560,39 @@ describe('container-move', () => {
expect(info?.currentCollectionName).toBe(plate.name)
})
})

describe('paper moves between same-named sheets', () => {
it('moves papers from Sheet-1 of one box to Sheet-1 of another using parent names', async () => {
const storageType = await createTestStorageType(testDb, { name: 'Freezer' })
const location = await createTestLocation(testDb, { name: 'Loc', storageTypeId: String(storageType.id) })
const specimenType = await createTestSpecimenType(testDb, { name: 'Blood' })
const specimen = await createTestSpecimen(testDb, specimenType.id)
const unit = await createTestUnit(testDb, { symbol: 'uL', name: 'microliter', category: 'volume' })
const now = utcNow()
const [boxA] = await testDb.insert(box).values({ name: 'BA', locationId: location.id, created: now, lastUpdated: now }).returning()
const [boxB] = await testDb.insert(box).values({ name: 'BB', locationId: location.id, created: now, lastUpdated: now }).returning()
const [sheetA] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxA.id }).returning()
const [sheetB] = await testDb.insert(sheet).values({ name: 'Sheet-1', boxId: boxB.id }).returning()
const [container] = await testDb
.insert(storageContainer)
.values({ specimenId: specimen.id, unitId: unit.id, totalQuantity: 1, remainingQuantity: 1, created: now, lastUpdated: now })
.returning()
await testDb.insert(paper).values({ id: container!.id, sheetId: sheetB.id })

const ambiguous = await executeMoves(testDb, {
mappings: [{ fromCollectionName: 'Sheet-1', toCollectionName: 'Sheet-1' }],
moves: [{ identifier: { type: 'container_id', containerId: container!.id }, targetPosition: '' }],
})
expect(ambiguous.success).toBe(false)
expect(ambiguous.errors?.[0].error).toMatch(/set toParentName/)

const result = await executeMoves(testDb, {
mappings: [{ fromCollectionName: 'Sheet-1', fromParentName: 'BB', toCollectionName: 'Sheet-1', toParentName: 'BA' }],
moves: [{ identifier: { type: 'container_id', containerId: container!.id }, targetPosition: '' }],
})
expect(result.success).toBe(true)
const [moved] = await testDb.select().from(paper).all()
expect(moved.sheetId).toBe(sheetA.id)
})
})
})
18 changes: 18 additions & 0 deletions packages/api/src/lib/__tests__/datetime.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest'
import { shiftCalendarDate } from '../datetime'

describe('shiftCalendarDate', () => {
it('rolls over months and years', () => {
expect(shiftCalendarDate('2024-01-31', 1)).toBe('2024-02-01')
expect(shiftCalendarDate('2024-03-01', -1)).toBe('2024-02-29')
expect(shiftCalendarDate('2023-12-31', 1)).toBe('2024-01-01')
})

it('reads the date part of a timestamp', () => {
expect(shiftCalendarDate('2024-05-26 22:28:26', 0)).toBe('2024-05-26')
})

it('returns null for text that does not start with a date', () => {
expect(shiftCalendarDate('1/5/2024', 1)).toBeNull()
})
})
Loading
Loading