Spaces:
Sleeping
Sleeping
File size: 9,790 Bytes
1f5ea39 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 | /**
* Unit tests for migration 69 (normalized notification preferences).
* Covers MIGR-001 to MIGR-004.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
function buildFreshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
return db;
}
/**
* Run all migrations up to (but NOT including) migration 69, then return the db.
* This allows us to set up old-schema data and test that migration 69 handles it.
*
* We do this by running only the schema tables that existed before migration 69,
* seeding old data, then running migration 69 in isolation.
*/
function setupPreMigration69Db() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA foreign_keys = ON');
// Create schema_version and users table (bare minimum)
db.exec(`
CREATE TABLE IF NOT EXISTS schema_version (version INTEGER NOT NULL DEFAULT 0);
INSERT INTO schema_version (version) VALUES (0);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
email TEXT,
role TEXT NOT NULL DEFAULT 'user'
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT
);
CREATE TABLE IF NOT EXISTS notification_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
notify_trip_invite INTEGER DEFAULT 1,
notify_booking_change INTEGER DEFAULT 1,
notify_trip_reminder INTEGER DEFAULT 1,
notify_vacay_invite INTEGER DEFAULT 1,
notify_photos_shared INTEGER DEFAULT 1,
notify_collab_message INTEGER DEFAULT 1,
notify_packing_tagged INTEGER DEFAULT 1,
notify_webhook INTEGER DEFAULT 1,
UNIQUE(user_id)
);
`);
return db;
}
/**
* Extract and run only migration 69 (index 68) from the migrations array.
* We do this by importing migrations and calling the last one directly.
*/
function runMigration69(db: ReturnType<typeof Database>): void {
// Migration 69 logic extracted inline for isolation
db.exec(`
CREATE TABLE IF NOT EXISTS notification_channel_preferences (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_type TEXT NOT NULL,
channel TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
PRIMARY KEY (user_id, event_type, channel)
);
CREATE INDEX IF NOT EXISTS idx_ncp_user ON notification_channel_preferences(user_id);
`);
const oldPrefs = db.prepare('SELECT * FROM notification_preferences').all() as Array<Record<string, number>>;
const eventCols: Record<string, string> = {
trip_invite: 'notify_trip_invite',
booking_change: 'notify_booking_change',
trip_reminder: 'notify_trip_reminder',
vacay_invite: 'notify_vacay_invite',
photos_shared: 'notify_photos_shared',
collab_message: 'notify_collab_message',
packing_tagged: 'notify_packing_tagged',
};
const insert = db.prepare(
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
);
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
for (const [userId, eventType, channel, enabled] of rows) {
insert.run(userId, eventType, channel, enabled);
}
});
for (const row of oldPrefs) {
const userId = row.user_id as number;
const webhookEnabled = (row.notify_webhook as number) ?? 0;
const rows: Array<[number, string, string, number]> = [];
for (const [eventType, col] of Object.entries(eventCols)) {
const emailEnabled = (row[col] as number) ?? 1;
if (!emailEnabled) rows.push([userId, eventType, 'email', 0]);
if (!webhookEnabled) rows.push([userId, eventType, 'webhook', 0]);
}
if (rows.length > 0) insertMany(rows);
}
db.exec(`
INSERT OR IGNORE INTO app_settings (key, value)
SELECT 'notification_channels', value FROM app_settings WHERE key = 'notification_channel';
`);
}
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// Migration 69 tests
// βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
describe('Migration 69 β normalized notification_channel_preferences', () => {
it('MIGR-001 β notification_channel_preferences table exists after migration', () => {
const db = setupPreMigration69Db();
runMigration69(db);
const table = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
).get();
expect(table).toBeDefined();
db.close();
});
it('MIGR-002 β old notification_preferences rows with disabled events migrated as enabled=0', () => {
const db = setupPreMigration69Db();
// Create a user
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
// Simulate user who has disabled trip_invite and booking_change email
db.prepare(`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
`).run(userId);
runMigration69(db);
const tripInviteEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
const bookingEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
const reminderEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
// Disabled events should have enabled=0 rows
expect(tripInviteEmail).toBeDefined();
expect(tripInviteEmail!.enabled).toBe(0);
expect(bookingEmail).toBeDefined();
expect(bookingEmail!.enabled).toBe(0);
// Enabled events should have no row (no-row = enabled)
expect(reminderEmail).toBeUndefined();
db.close();
});
it('MIGR-003 β old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
const db = setupPreMigration69Db();
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
// User has all email enabled but webhook disabled
db.prepare(`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
`).run(userId);
runMigration69(db);
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
for (const eventType of allEvents) {
const row = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
// Email rows should NOT exist (all email was enabled β no row needed)
const emailRow = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'email');
expect(emailRow).toBeUndefined();
}
db.close();
});
it('MIGR-004 β notification_channels key is created in app_settings from notification_channel value', () => {
const db = setupPreMigration69Db();
// Simulate existing single-channel setting
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
runMigration69(db);
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
expect(plural).toBeDefined();
expect(plural!.value).toBe('email');
db.close();
});
it('MIGR-004b β notification_channels is not duplicated if already exists', () => {
const db = setupPreMigration69Db();
// Both keys already set (e.g. partial migration or manual edit)
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channel', 'email');
db.prepare('INSERT INTO app_settings (key, value) VALUES (?, ?)').run('notification_channels', 'email,webhook');
runMigration69(db);
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
expect(plural!.value).toBe('email,webhook');
db.close();
});
});
|