From f654d82b4b751dd5b1dcc2b939eaf0e196755fcf Mon Sep 17 00:00:00 2001 From: Alec Schitzkat Date: Wed, 19 Aug 2026 19:31:39 +0200 Subject: [PATCH] FIX - Improve test data idempotency and reliability Refactor test data seeding to use deterministic identifiers for DarkChat and Flare, ensuring re-runs don't create duplicate entries or inconsistent states. Implement robust SIM card movement logic and add contract tests to verify seeding constraints and default configuration safety. --- frontend/src/testdata.contract.test.ts | 69 ++++++++++++++ sky_phone/config/config.lua | 2 +- sky_phone/source/server/testdata.lua | 120 ++++++++++++++++++++++--- 3 files changed, 179 insertions(+), 12 deletions(-) create mode 100644 frontend/src/testdata.contract.test.ts diff --git a/frontend/src/testdata.contract.test.ts b/frontend/src/testdata.contract.test.ts new file mode 100644 index 0000000..35272f6 --- /dev/null +++ b/frontend/src/testdata.contract.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from 'node:fs' + +import { describe, expect, it } from 'vitest' + +const readResourceFile = (path: string) => + readFileSync(new URL(`../../sky_phone/${path}`, import.meta.url), 'utf8') + +const config = readResourceFile('config/config.lua') +const testData = readResourceFile('source/server/testdata.lua') + +describe('test data seeding contracts', () => { + it('keeps test data disabled while retaining the development phone command', () => { + expect(config).toContain('DevelopmentCommand = true') + expect(config).toContain( + 'Enabled = false, -- development/test servers only', + ) + }) + + it('returns before registering the test-data command when disabled', () => { + expect( + testData.indexOf('if not Config.TestData.Enabled then'), + ).toBeLessThan(testData.indexOf('RegisterCommand(Config.TestData.Command')) + }) + + it('moves an existing player SIM before attaching it to the selected phone', () => { + expect(testData).toContain('local function move_sim_to_device') + expect(testData).toContain( + 'SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?', + ) + expect(testData).toContain( + 'SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL', + ) + expect(testData).toContain( + 'local previous_imei = move_sim_to_device(sim.id, imei)', + ) + expect(testData).toContain( + 'restore_sim_attachment(sim.id, imei, previous_imei)', + ) + }) + + it('derives distinct DarkChat identifiers from each account', () => { + expect(testData).toContain( + 'local function darkchat_identifiers(account_id)', + ) + expect(testData).toContain( + 'local user_dark_id, user_invite_code = darkchat_identifiers(account_id)', + ) + expect(testData).toContain( + 'local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id)', + ) + expect(testData).not.toContain("'DARK0000000001'") + expect(testData).not.toContain("'INV00000001'") + }) + + it('loads the persisted Flare match before inserting its test message', () => { + const matchLookup = testData.indexOf( + 'SELECT `id` FROM `sky_phone_flare_matches`', + ) + const messageInsert = testData.indexOf( + 'INSERT INTO `sky_phone_flare_messages`', + ) + + expect(matchLookup).toBeGreaterThan(-1) + expect(messageInsert).toBeGreaterThan(matchLookup) + expect(testData).toContain( + 'stable_uuid("sky_phone:testdata:flare:message:" .. match_id)', + ) + }) +}) diff --git a/sky_phone/config/config.lua b/sky_phone/config/config.lua index 8c79661..ef6fed4 100644 --- a/sky_phone/config/config.lua +++ b/sky_phone/config/config.lua @@ -34,7 +34,7 @@ Config.Phone = { } Config.TestData = { - Enabled = true, + Enabled = false, -- development/test servers only; keep disabled in production Command = "phonetestdata", AdminOnly = false, -- enable only on development servers; every run is scoped to the executing player's phone AdminGroups = { "admin", "superadmin" }, diff --git a/sky_phone/source/server/testdata.lua b/sky_phone/source/server/testdata.lua index bc466c3..657f68f 100644 --- a/sky_phone/source/server/testdata.lua +++ b/sky_phone/source/server/testdata.lua @@ -43,6 +43,13 @@ local function seed_hash(seed) return value end +local function darkchat_identifiers(account_id) + local account_key = tostring(account_id) + local dark_id = "DC" .. seed_hash("darkchat:id:" .. account_key):upper() + local invite_code = "I" .. seed_hash("darkchat:invite:" .. account_key):sub(1, 10):upper() + return dark_id, invite_code +end + local function database_uuid() local rows = Bridge.Database.Query("SELECT UUID() AS `id`", {}) local id = rows[1] and rows[1].id @@ -127,6 +134,71 @@ local function reserve_sim(owner_identifier, firstname, lastname) return { id = sim_id, phone_number = number, sim_type = "registered" } end +local function restore_sim_attachment(sim_id, current_imei, previous_imei) + local statements = { + { + query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `imei` = ? AND `sim_id` = ?", + params = { current_imei, sim_id }, + }, + } + if previous_imei then + statements[#statements + 1] = { + query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL", + params = { sim_id, previous_imei }, + } + end + if not Bridge.Database.Transaction(statements) then + return false + end + + local rows = Bridge.Database.Query( + "SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1", + { sim_id } + ) + if previous_imei then + return rows[1] and rows[1].imei == previous_imei + end + return rows[1] == nil +end + +local function move_sim_to_device(sim_id, imei) + local rows = Bridge.Database.Query( + "SELECT `imei` FROM `sky_phone_devices` WHERE `sim_id` = ? LIMIT 1", + { sim_id } + ) + local previous_imei = rows[1] and rows[1].imei or nil + if previous_imei == imei then + return previous_imei + end + + local moved = Bridge.Database.Transaction({ + { + query = "UPDATE `sky_phone_devices` SET `sim_id` = NULL WHERE `sim_id` = ? AND `imei` <> ?", + params = { sim_id, imei }, + }, + { + query = "UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ? AND `sim_id` IS NULL", + params = { sim_id, imei }, + }, + }) + if not moved then + error("[sky_phone] Test data could not move the player's SIM to the selected phone.") + end + + rows = Bridge.Database.Query( + "SELECT `sim_id` FROM `sky_phone_devices` WHERE `imei` = ? LIMIT 1", + { imei } + ) + if not rows[1] or rows[1].sim_id ~= sim_id then + if not restore_sim_attachment(sim_id, imei, previous_imei) then + error("[sky_phone] Test data could not verify the SIM move or restore its previous device.") + end + error("[sky_phone] Test data could not verify the SIM move.") + end + + return previous_imei +end + local function ensure_bot(label, email_local, imei, firstname, lastname) local account = ensure_account(email_local .. "@" .. Config.Mail.Domain) local sim = reserve_sim("sky_phone:testbot:" .. label, firstname, lastname) @@ -667,36 +739,57 @@ local function seed_social_apps(context) INSERT IGNORE INTO `sky_phone_flare_profile_photos` (`profile_id`, `media_id`, `sort_order`) VALUES (?, ?, 1), (?, ?, 1) ]], { flare_user, context.media.user_portrait, flare_bot, context.media.bot_two_portrait }) - local match_id = stable_uuid(context.key .. ":flare:match") local account_a = math.min(account_id, bot_two_id) local account_b = math.max(account_id, bot_two_id) + local proposed_match_id = stable_uuid( + ("sky_phone:testdata:flare:match:%s:%s"):format(account_a, account_b) + ) Bridge.Database.Query([[ INSERT INTO `sky_phone_flare_matches` (`id`, `account_a_id`, `account_b_id`) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE `created_at` = `created_at` - ]], { match_id, account_a, account_b }) - local flare_message = stable_uuid(context.key .. ":flare:message") + ]], { proposed_match_id, account_a, account_b }) + local match_rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_flare_matches` + WHERE `account_a_id` = ? AND `account_b_id` = ? LIMIT 1 + ]], { account_a, account_b }) + local match_id = match_rows[1] and match_rows[1].id or nil + if type(match_id) ~= "string" then + error("[sky_phone] Test data Flare match could not be loaded.") + end + local flare_body = "Hey! Bereit für einen vollständigen App-Test?" + local message_rows = Bridge.Database.Query([[ + SELECT `id` FROM `sky_phone_flare_messages` + WHERE `match_id` = ? AND `sender_account_id` = ? AND `body` = ? + ORDER BY `created_at`, `id` LIMIT 1 + ]], { match_id, bot_two_id, flare_body }) + local flare_message = message_rows[1] and message_rows[1].id + or stable_uuid("sky_phone:testdata:flare:message:" .. match_id) Bridge.Database.Query([[ INSERT INTO `sky_phone_flare_messages` (`id`, `match_id`, `sender_account_id`, `body`, `read_at`) - VALUES (?, ?, ?, 'Hey! Bereit für einen vollständigen App-Test?', NULL) + VALUES (?, ?, ?, ?, NULL) ON DUPLICATE KEY UPDATE `body` = VALUES(`body`), `read_at` = NULL - ]], { flare_message, match_id, bot_two_id }) + ]], { flare_message, match_id, bot_two_id, flare_body }) end local function seed_private_and_services(context) local account_id = context.account.id local bot_id = context.bot_one.account.id + local user_dark_id, user_invite_code = darkchat_identifiers(account_id) + local bot_dark_id, bot_invite_code = darkchat_identifiers(bot_id) Bridge.Database.Query([[ INSERT INTO `sky_phone_darkchat_profiles` (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) VALUES (?, ?, ?, 'NightTester', 42, 'private', 1) - ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`) - ]], { account_id, ("DARK%010d"):format(account_id % 10000000000), ("INV%08d"):format(account_id % 100000000) }) + ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`), + `alias` = VALUES(`alias`), `notification_mode` = VALUES(`notification_mode`) + ]], { account_id, user_dark_id, user_invite_code }) Bridge.Database.Query([[ INSERT INTO `sky_phone_darkchat_profiles` (`account_id`, `dark_id`, `invite_code`, `alias`, `avatar_seed`, `notification_mode`, `activity_visible`) - VALUES (?, 'DARK0000000001', 'INV00000001', 'GhostAlex', 17, 'full', 1) - ON DUPLICATE KEY UPDATE `alias` = VALUES(`alias`) - ]], { bot_id }) + VALUES (?, ?, ?, 'GhostAlex', 17, 'full', 1) + ON DUPLICATE KEY UPDATE `dark_id` = VALUES(`dark_id`), `invite_code` = VALUES(`invite_code`), + `alias` = VALUES(`alias`) + ]], { bot_id, bot_dark_id, bot_invite_code }) local dark_user = ensure_numeric_profile("sky_phone_darkchat_profiles", account_id) local dark_bot = ensure_numeric_profile("sky_phone_darkchat_profiles", bot_id) Bridge.Database.Query([[ @@ -897,7 +990,7 @@ local function seed_for_source(source) sim = rows[1] else sim = reserve_sim(identifier, Bridge.Framework.GetFirstname(source), Bridge.Framework.GetLastname(source)) - Bridge.Database.Query("UPDATE `sky_phone_devices` SET `sim_id` = ? WHERE `imei` = ?", { sim.id, imei }) + local previous_imei = move_sim_to_device(sim.id, imei) local metadata = phone_slot.metadata or {} metadata.sim_id = sim.id metadata.phone_number = sim.phone_number @@ -908,6 +1001,11 @@ local function seed_for_source(source) Config.Sim.NumberPrefix ) if not Bridge.Inventory.SetSlotMetadata(source, phone_slot.slot, metadata) then + if not restore_sim_attachment(sim.id, imei, previous_imei) then + error( + "[sky_phone] Test data could not update the phone item's SIM metadata or restore its previous device." + ) + end error("[sky_phone] Test data could not update the phone item's SIM metadata.") end end