Sasha commited on
Commit
e0e1da7
·
1 Parent(s): 78d6a51

Optimize moderation logging: fix duplicate collisions, auto-refresh streamer OAuth tokens, and implement IRC keep-alive timeouts. Add unit tests.

Browse files
local_worker/chat_mod_worker.py CHANGED
@@ -76,8 +76,14 @@ def twitch_irc_listener():
76
 
77
  buffer = ""
78
  irc_sock.setblocking(False)
 
79
 
80
  while not stop_flag.is_set():
 
 
 
 
 
81
  ready = select.select([irc_sock], [], [], 1.0)
82
  if not ready[0]:
83
  continue
@@ -91,6 +97,7 @@ def twitch_irc_listener():
91
  print("[Twitch IRC] Connection closed by remote host.")
92
  break
93
 
 
94
  buffer += data
95
  while "\r\n" in buffer:
96
  line, buffer = buffer.split("\r\n", 1)
 
76
 
77
  buffer = ""
78
  irc_sock.setblocking(False)
79
+ last_msg_time = time.time()
80
 
81
  while not stop_flag.is_set():
82
+ # Check for network timeout (no messages for 5 minutes)
83
+ if time.time() - last_msg_time > 300:
84
+ print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...")
85
+ break
86
+
87
  ready = select.select([irc_sock], [], [], 1.0)
88
  if not ready[0]:
89
  continue
 
97
  print("[Twitch IRC] Connection closed by remote host.")
98
  break
99
 
100
+ last_msg_time = time.time()
101
  buffer += data
102
  while "\r\n" in buffer:
103
  line, buffer = buffer.split("\r\n", 1)
local_worker/worker.py CHANGED
@@ -102,8 +102,14 @@ def twitch_chat_listener():
102
 
103
  buffer = ""
104
  irc_sock.setblocking(False)
 
105
 
106
  while not stop_flag.is_set():
 
 
 
 
 
107
  # Use select for non-blocking read with timeout to allow exit checks
108
  ready = select.select([irc_sock], [], [], 1.0)
109
  if not ready[0]:
@@ -118,6 +124,7 @@ def twitch_chat_listener():
118
  print("[Twitch IRC] Connection closed by remote host.")
119
  break
120
 
 
121
  buffer += data
122
  while "\r\n" in buffer:
123
  line, buffer = buffer.split("\r\n", 1)
 
102
 
103
  buffer = ""
104
  irc_sock.setblocking(False)
105
+ last_msg_time = time.time()
106
 
107
  while not stop_flag.is_set():
108
+ # Check for network timeout (no messages for 5 minutes)
109
+ if time.time() - last_msg_time > 300:
110
+ print("[Twitch IRC] Network timeout (no messages for 5 minutes). Reconnecting...")
111
+ break
112
+
113
  # Use select for non-blocking read with timeout to allow exit checks
114
  ready = select.select([irc_sock], [], [], 1.0)
115
  if not ready[0]:
 
124
  print("[Twitch IRC] Connection closed by remote host.")
125
  break
126
 
127
+ last_msg_time = time.time()
128
  buffer += data
129
  while "\r\n" in buffer:
130
  line, buffer = buffer.split("\r\n", 1)
server/db.js CHANGED
@@ -30,7 +30,7 @@ console.log(`[Database] Initializing in Mode: ${dbMode.toUpperCase()}`);
30
  let sqliteDb = null;
31
 
32
  if (dbMode === 'sqlite') {
33
- const dbPath = path.resolve('database.db');
34
  console.log(`[Database] SQLite file location: ${dbPath}`);
35
  sqliteDb = new Database(dbPath);
36
 
@@ -625,18 +625,23 @@ export async function logModAction(action) {
625
  const streamId = stream ? stream.id : null;
626
 
627
  // Prevent duplicate actions (e.g., from both EventSub and local IRC worker running together)
 
628
  if (action.targetUser) {
629
  const targetUserLower = action.targetUser.toLowerCase();
630
  if (dbMode === 'sqlite') {
631
  const existing = sqliteDb.prepare(`
632
- SELECT id FROM mod_actions
633
  WHERE action_type = ?
634
  AND target_user = ?
635
  AND abs(strftime('%s', timestamp) - strftime('%s', ?)) < 15
636
  `).get(action.actionType, targetUserLower, time);
637
  if (existing) {
638
- console.log(`[Database] Duplicate SQLite mod action ${action.actionType} for ${targetUserLower} ignored.`);
639
- return;
 
 
 
 
640
  }
641
  } else {
642
  const timeMs = new Date(time).getTime();
@@ -645,7 +650,7 @@ export async function logModAction(action) {
645
 
646
  const { data: existing, error } = await supabase
647
  .from('mod_actions')
648
- .select('id')
649
  .eq('action_type', action.actionType)
650
  .eq('target_user', targetUserLower)
651
  .gte('timestamp', startTimeRange)
@@ -653,8 +658,13 @@ export async function logModAction(action) {
653
  .limit(1);
654
 
655
  if (!error && existing && existing.length > 0) {
656
- console.log(`[Database] Duplicate Supabase mod action ${action.actionType} for ${targetUserLower} ignored.`);
657
- return;
 
 
 
 
 
658
  }
659
  }
660
  }
@@ -706,36 +716,66 @@ export async function logModAction(action) {
706
  }
707
 
708
  if (dbMode === 'sqlite') {
709
- sqliteDb.prepare(`
710
- INSERT INTO mod_actions (stream_id, action_type, moderator, target_user, duration, reason, message_text, timestamp, reaction_time)
711
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
712
- `).run(
713
- streamId,
714
- action.actionType,
715
- action.moderator,
716
- action.targetUser ? action.targetUser.toLowerCase() : null,
717
- action.duration || null,
718
- action.reason || null,
719
- action.messageText || null,
720
- time,
721
- reactionTime
722
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
723
  } else {
724
- const { error } = await supabase
725
- .from('mod_actions')
726
- .insert([{
727
- stream_id: streamId,
728
- action_type: action.actionType,
729
- moderator: action.moderator,
730
- target_user: action.targetUser ? action.targetUser.toLowerCase() : null,
731
- duration: action.duration || null,
732
- reason: action.reason || null,
733
- message_text: action.messageText || null,
734
- timestamp: time,
735
- reaction_time: reactionTime
736
- }]);
737
- if (error) {
738
- console.error('[Supabase] Error logging mod action:', error);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
739
  }
740
  }
741
  }
 
30
  let sqliteDb = null;
31
 
32
  if (dbMode === 'sqlite') {
33
+ const dbPath = path.resolve(process.env.SQLITE_DB_PATH || 'database.db');
34
  console.log(`[Database] SQLite file location: ${dbPath}`);
35
  sqliteDb = new Database(dbPath);
36
 
 
625
  const streamId = stream ? stream.id : null;
626
 
627
  // Prevent duplicate actions (e.g., from both EventSub and local IRC worker running together)
628
+ let duplicateToUpdate = null;
629
  if (action.targetUser) {
630
  const targetUserLower = action.targetUser.toLowerCase();
631
  if (dbMode === 'sqlite') {
632
  const existing = sqliteDb.prepare(`
633
+ SELECT id, moderator FROM mod_actions
634
  WHERE action_type = ?
635
  AND target_user = ?
636
  AND abs(strftime('%s', timestamp) - strftime('%s', ?)) < 15
637
  `).get(action.actionType, targetUserLower, time);
638
  if (existing) {
639
+ if (existing.moderator === 'TwitchIRC' && action.moderator !== 'TwitchIRC') {
640
+ duplicateToUpdate = existing.id;
641
+ } else {
642
+ console.log(`[Database] Duplicate SQLite mod action ${action.actionType} for ${targetUserLower} ignored.`);
643
+ return;
644
+ }
645
  }
646
  } else {
647
  const timeMs = new Date(time).getTime();
 
650
 
651
  const { data: existing, error } = await supabase
652
  .from('mod_actions')
653
+ .select('id, moderator')
654
  .eq('action_type', action.actionType)
655
  .eq('target_user', targetUserLower)
656
  .gte('timestamp', startTimeRange)
 
658
  .limit(1);
659
 
660
  if (!error && existing && existing.length > 0) {
661
+ const first = existing[0];
662
+ if (first.moderator === 'TwitchIRC' && action.moderator !== 'TwitchIRC') {
663
+ duplicateToUpdate = first.id;
664
+ } else {
665
+ console.log(`[Database] Duplicate Supabase mod action ${action.actionType} for ${targetUserLower} ignored.`);
666
+ return;
667
+ }
668
  }
669
  }
670
  }
 
716
  }
717
 
718
  if (dbMode === 'sqlite') {
719
+ if (duplicateToUpdate) {
720
+ sqliteDb.prepare(`
721
+ UPDATE mod_actions
722
+ SET moderator = ?, reason = ?, reaction_time = ?
723
+ WHERE id = ?
724
+ `).run(
725
+ action.moderator,
726
+ action.reason || null,
727
+ reactionTime,
728
+ duplicateToUpdate
729
+ );
730
+ console.log(`[Database] Duplicate SQLite mod action ${action.actionType} updated with real moderator ${action.moderator}.`);
731
+ } else {
732
+ sqliteDb.prepare(`
733
+ INSERT INTO mod_actions (stream_id, action_type, moderator, target_user, duration, reason, message_text, timestamp, reaction_time)
734
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
735
+ `).run(
736
+ streamId,
737
+ action.actionType,
738
+ action.moderator,
739
+ action.targetUser ? action.targetUser.toLowerCase() : null,
740
+ action.duration || null,
741
+ action.reason || null,
742
+ action.messageText || null,
743
+ time,
744
+ reactionTime
745
+ );
746
+ }
747
  } else {
748
+ if (duplicateToUpdate) {
749
+ const { error } = await supabase
750
+ .from('mod_actions')
751
+ .update({
752
+ moderator: action.moderator,
753
+ reason: action.reason || null,
754
+ reaction_time: reactionTime
755
+ })
756
+ .eq('id', duplicateToUpdate);
757
+ if (error) {
758
+ console.error('[Supabase] Error updating mod action duplicate:', error);
759
+ } else {
760
+ console.log(`[Database] Duplicate Supabase mod action ${action.actionType} updated with real moderator ${action.moderator}.`);
761
+ }
762
+ } else {
763
+ const { error } = await supabase
764
+ .from('mod_actions')
765
+ .insert([{
766
+ stream_id: streamId,
767
+ action_type: action.actionType,
768
+ moderator: action.moderator,
769
+ target_user: action.targetUser ? action.targetUser.toLowerCase() : null,
770
+ duration: action.duration || null,
771
+ reason: action.reason || null,
772
+ message_text: action.messageText || null,
773
+ timestamp: time,
774
+ reaction_time: reactionTime
775
+ }]);
776
+ if (error) {
777
+ console.error('[Supabase] Error logging mod action:', error);
778
+ }
779
  }
780
  }
781
  }
server/eventsub.js CHANGED
@@ -1,6 +1,6 @@
1
  import WebSocket from 'ws';
2
  import axios from 'axios';
3
- import { getSetting, logModAction } from './db.js';
4
 
5
  let ws = null;
6
  let broadcasterUserId = null;
@@ -8,6 +8,59 @@ let keepAliveTimer = null;
8
  let lastKeepAlive = null;
9
  let reconnecting = false;
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  /**
12
  * Initialize Twitch EventSub WebSocket connection
13
  */
@@ -24,9 +77,9 @@ export async function initializeEventSub(streamerId = null) {
24
  return;
25
  }
26
 
27
- const token = await getSetting('twitch_streamer_access_token');
28
  if (!token) {
29
- console.log('[EventSub] Streamer access token is missing. Waiting for Streamer login.');
30
  return;
31
  }
32
 
@@ -168,7 +221,7 @@ async function handleNotification(payload) {
168
  */
169
  async function subscribeToEvents(sessionId) {
170
  const clientId = process.env.TWITCH_CLIENT_ID;
171
- const token = await getSetting('twitch_streamer_access_token');
172
 
173
  if (!clientId || !token) {
174
  console.error('[EventSub] Cannot subscribe: Client ID or token is missing!');
 
1
  import WebSocket from 'ws';
2
  import axios from 'axios';
3
+ import { getSetting, setSetting, logModAction } from './db.js';
4
 
5
  let ws = null;
6
  let broadcasterUserId = null;
 
8
  let lastKeepAlive = null;
9
  let reconnecting = false;
10
 
11
+ /**
12
+ * Validate current access token and refresh it if expired/invalid
13
+ */
14
+ export async function getOrRefreshAccessToken() {
15
+ let token = await getSetting('twitch_streamer_access_token');
16
+ const refreshToken = await getSetting('twitch_streamer_refresh_token');
17
+
18
+ if (!token) {
19
+ return null;
20
+ }
21
+
22
+ // Validate the current token via Twitch API
23
+ try {
24
+ await axios.get('https://id.twitch.tv/oauth2/validate', {
25
+ headers: {
26
+ 'Authorization': `Bearer ${token}`
27
+ }
28
+ });
29
+ // Token is valid!
30
+ return token;
31
+ } catch (err) {
32
+ const status = err.response?.status;
33
+ console.log(`[EventSub] Access token validation failed (Status: ${status}). Attempting to refresh...`);
34
+
35
+ if (!refreshToken) {
36
+ console.error('[EventSub] Refresh token is missing in database. Cannot refresh access token.');
37
+ return null;
38
+ }
39
+
40
+ try {
41
+ const response = await axios.post('https://id.twitch.tv/oauth2/token', null, {
42
+ params: {
43
+ client_id: process.env.TWITCH_CLIENT_ID,
44
+ client_secret: process.env.TWITCH_CLIENT_SECRET,
45
+ grant_type: 'refresh_token',
46
+ refresh_token: refreshToken
47
+ }
48
+ });
49
+
50
+ const { access_token, refresh_token: newRefreshToken } = response.data;
51
+ await setSetting('twitch_streamer_access_token', access_token);
52
+ if (newRefreshToken) {
53
+ await setSetting('twitch_streamer_refresh_token', newRefreshToken);
54
+ }
55
+ console.log('[EventSub] Access token refreshed successfully.');
56
+ return access_token;
57
+ } catch (refreshErr) {
58
+ console.error('[EventSub] Token refresh request failed:', refreshErr.response?.data || refreshErr.message);
59
+ return null;
60
+ }
61
+ }
62
+ }
63
+
64
  /**
65
  * Initialize Twitch EventSub WebSocket connection
66
  */
 
77
  return;
78
  }
79
 
80
+ const token = await getOrRefreshAccessToken();
81
  if (!token) {
82
+ console.log('[EventSub] Streamer access token is missing or invalid. Waiting for Streamer login.');
83
  return;
84
  }
85
 
 
221
  */
222
  async function subscribeToEvents(sessionId) {
223
  const clientId = process.env.TWITCH_CLIENT_ID;
224
+ const token = await getOrRefreshAccessToken();
225
 
226
  if (!clientId || !token) {
227
  console.error('[EventSub] Cannot subscribe: Client ID or token is missing!');
server/scratch/test_mod_duplicates.js ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Setup testing database environment variables BEFORE any imports
2
+ process.env.DB_MODE = 'sqlite';
3
+ process.env.SQLITE_DB_PATH = './test_database.db';
4
+ // Clear Supabase keys to force SQLite mode in db.js
5
+ process.env.SUPABASE_URL = '';
6
+ process.env.SUPABASE_KEY = '';
7
+ process.env.SUPABASE_SERVICE_ROLE_KEY = '';
8
+
9
+ import Database from 'better-sqlite3';
10
+ import fs from 'fs';
11
+
12
+ // Clean up test database
13
+ if (fs.existsSync('./test_database.db')) {
14
+ fs.unlinkSync('./test_database.db');
15
+ }
16
+
17
+ const sqliteDb = new Database('./test_database.db');
18
+
19
+ // Create minimal schema for testing
20
+ sqliteDb.exec(`
21
+ CREATE TABLE IF NOT EXISTS streams (
22
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
23
+ start_time TEXT,
24
+ end_time TEXT,
25
+ title TEXT,
26
+ twitch_stream_id TEXT UNIQUE,
27
+ category TEXT
28
+ );
29
+
30
+ CREATE TABLE IF NOT EXISTS mod_actions (
31
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
32
+ stream_id INTEGER,
33
+ action_type TEXT,
34
+ moderator TEXT,
35
+ target_user TEXT,
36
+ duration INTEGER,
37
+ reason TEXT,
38
+ message_text TEXT,
39
+ timestamp TEXT,
40
+ reaction_time REAL
41
+ );
42
+ `);
43
+
44
+ // Insert a fake stream
45
+ sqliteDb.prepare(`
46
+ INSERT INTO streams (id, start_time, title, twitch_stream_id, category)
47
+ VALUES (1, datetime('now'), 'Test Stream', 'test-stream-123', 'Testing')
48
+ `).run();
49
+
50
+ // Dynamically import db.js so it reads the env vars we just set
51
+ const { logModAction } = await import('../db.js');
52
+
53
+ async function runTest() {
54
+ console.log('--- STARTING DUPLICATE MERGE TEST ---');
55
+
56
+ const timestamp = new Date().toISOString();
57
+
58
+ // 1. Simulate IRC event (anonymous, moderator = TwitchIRC, no reason)
59
+ console.log('1. Logging IRC mod action (anonymous)...');
60
+ await logModAction({
61
+ actionType: 'timeout',
62
+ moderator: 'TwitchIRC',
63
+ targetUser: 'spammer_user',
64
+ duration: 600,
65
+ timestamp: timestamp
66
+ });
67
+
68
+ // Verify it exists in DB
69
+ let record = sqliteDb.prepare('SELECT * FROM mod_actions WHERE target_user = ?').get('spammer_user');
70
+ console.log('Record after IRC:', record);
71
+ if (!record || record.moderator !== 'TwitchIRC' || record.reason !== null) {
72
+ console.error('FAIL: Initial IRC record is incorrect.');
73
+ process.exit(1);
74
+ }
75
+
76
+ // 2. Simulate EventSub event (real moderator, custom reason, same timestamp)
77
+ console.log('2. Logging EventSub mod action (with real mod & reason)...');
78
+ await logModAction({
79
+ actionType: 'timeout',
80
+ moderator: 'GoodModName',
81
+ targetUser: 'spammer_user',
82
+ duration: 600,
83
+ reason: 'Links spamming in chat',
84
+ timestamp: timestamp
85
+ });
86
+
87
+ // Verify it updated in DB instead of creating a duplicate or ignoring
88
+ const records = sqliteDb.prepare('SELECT * FROM mod_actions WHERE target_user = ?').all('spammer_user');
89
+ console.log('All records in DB after EventSub:', records);
90
+
91
+ if (records.length !== 1) {
92
+ console.error(`FAIL: Expected exactly 1 record, found ${records.length}`);
93
+ process.exit(1);
94
+ }
95
+
96
+ record = records[0];
97
+ if (record.moderator !== 'goodmodname' && record.moderator !== 'GoodModName') { // depending on casing
98
+ console.error(`FAIL: Expected moderator to be GoodModName/goodmodname, got: ${record.moderator}`);
99
+ process.exit(1);
100
+ }
101
+
102
+ if (record.reason !== 'Links spamming in chat') {
103
+ console.error(`FAIL: Expected reason "Links spamming in chat", got: ${record.reason}`);
104
+ process.exit(1);
105
+ }
106
+
107
+ console.log('SUCCESS: Duplicate mod action successfully merged/enriched!');
108
+
109
+ // Clean up
110
+ sqliteDb.close();
111
+ if (fs.existsSync('./test_database.db')) {
112
+ fs.unlinkSync('./test_database.db');
113
+ }
114
+ }
115
+
116
+ runTest().catch(err => {
117
+ console.error('Test crashed:', err);
118
+ process.exit(1);
119
+ });
server/scratch/test_token_refresh.js ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Setup testing database environment variables BEFORE any imports
2
+ process.env.DB_MODE = 'sqlite';
3
+ process.env.SQLITE_DB_PATH = './test_database.db';
4
+ // Clear Supabase keys to force SQLite mode
5
+ process.env.SUPABASE_URL = '';
6
+ process.env.SUPABASE_KEY = '';
7
+ process.env.SUPABASE_SERVICE_ROLE_KEY = '';
8
+
9
+ // Mock environment variables for Twitch client
10
+ process.env.TWITCH_CLIENT_ID = 'mocked_client_id';
11
+ process.env.TWITCH_CLIENT_SECRET = 'mocked_client_secret';
12
+
13
+ import Database from 'better-sqlite3';
14
+ import fs from 'fs';
15
+ import axios from 'axios';
16
+
17
+ // Clean up test database
18
+ if (fs.existsSync('./test_database.db')) {
19
+ fs.unlinkSync('./test_database.db');
20
+ }
21
+
22
+ const sqliteDb = new Database('./test_database.db');
23
+
24
+ // Create settings table
25
+ sqliteDb.exec(`
26
+ CREATE TABLE IF NOT EXISTS settings (
27
+ key TEXT PRIMARY KEY,
28
+ value TEXT NOT NULL
29
+ );
30
+ `);
31
+
32
+ // Insert initial expired token and refresh token
33
+ sqliteDb.prepare(`
34
+ INSERT INTO settings (key, value)
35
+ VALUES ('twitch_streamer_access_token', 'expired_token_value')
36
+ `).run();
37
+
38
+ sqliteDb.prepare(`
39
+ INSERT INTO settings (key, value)
40
+ VALUES ('twitch_streamer_refresh_token', 'valid_refresh_token_value')
41
+ `).run();
42
+
43
+ // Mock Axios methods
44
+ let validateCalls = 0;
45
+ let refreshCalls = 0;
46
+ let refreshParamsReceived = null;
47
+
48
+ axios.get = async (url, config) => {
49
+ if (url === 'https://id.twitch.tv/oauth2/validate') {
50
+ validateCalls++;
51
+ const authHeader = config?.headers?.Authorization;
52
+ if (authHeader === 'Bearer valid_mocked_token') {
53
+ // Mock success for valid token
54
+ return { data: { client_id: 'mocked_client_id' } };
55
+ } else {
56
+ // Mock expiration/invalid token error
57
+ const err = new Error('Unauthorized');
58
+ err.response = { status: 401, data: { message: 'invalid oauth token' } };
59
+ throw err;
60
+ }
61
+ }
62
+ throw new Error(`Unexpected GET request to ${url}`);
63
+ };
64
+
65
+ axios.post = async (url, data, config) => {
66
+ if (url === 'https://id.twitch.tv/oauth2/token') {
67
+ refreshCalls++;
68
+ refreshParamsReceived = config?.params;
69
+ return {
70
+ data: {
71
+ access_token: 'new_refreshed_access_token',
72
+ refresh_token: 'new_refreshed_refresh_token'
73
+ }
74
+ };
75
+ }
76
+ throw new Error(`Unexpected POST request to ${url}`);
77
+ };
78
+
79
+ // Dynamically import db.js and eventsub.js so they read correct environment variables and use the mocked axios
80
+ const { getSetting } = await import('../db.js');
81
+ const { getOrRefreshAccessToken } = await import('../eventsub.js');
82
+
83
+ async function runTest() {
84
+ console.log('--- STARTING TOKEN REFRESH TEST ---');
85
+
86
+ // Scenario 1: Initial call with expired token. Should fail validate, call refresh, and return new token.
87
+ console.log('Scenario 1: Testing token validation failure and refresh flow...');
88
+ const token = await getOrRefreshAccessToken();
89
+
90
+ console.log('Returned token:', token);
91
+ console.log('Validate calls:', validateCalls);
92
+ console.log('Refresh calls:', refreshCalls);
93
+ console.log('Params received on refresh:', refreshParamsReceived);
94
+
95
+ // Assertions
96
+ if (token !== 'new_refreshed_access_token') {
97
+ console.error('FAIL: Expected new refreshed access token, got:', token);
98
+ process.exit(1);
99
+ }
100
+ if (validateCalls !== 1) {
101
+ console.error('FAIL: Expected 1 validate call, got:', validateCalls);
102
+ process.exit(1);
103
+ }
104
+ if (refreshCalls !== 1) {
105
+ console.error('FAIL: Expected 1 refresh call, got:', refreshCalls);
106
+ process.exit(1);
107
+ }
108
+ if (!refreshParamsReceived || refreshParamsReceived.refresh_token !== 'valid_refresh_token_value') {
109
+ console.error('FAIL: Incorrect refresh token sent in params:', refreshParamsReceived);
110
+ process.exit(1);
111
+ }
112
+
113
+ // Verify DB updated settings
114
+ const dbAccessToken = sqliteDb.prepare("SELECT value FROM settings WHERE key = 'twitch_streamer_access_token'").get()?.value;
115
+ const dbRefreshToken = sqliteDb.prepare("SELECT value FROM settings WHERE key = 'twitch_streamer_refresh_token'").get()?.value;
116
+
117
+ console.log('DB Access Token:', dbAccessToken);
118
+ console.log('DB Refresh Token:', dbRefreshToken);
119
+
120
+ if (dbAccessToken !== 'new_refreshed_access_token' || dbRefreshToken !== 'new_refreshed_refresh_token') {
121
+ console.error('FAIL: Settings not updated in database correctly');
122
+ process.exit(1);
123
+ }
124
+
125
+ // Scenario 2: Subsequent call with the now valid token. Should validate successfully and not call refresh.
126
+ console.log('Scenario 2: Testing valid token bypasses refresh...');
127
+ // Update mock token validation check to succeed for this new token
128
+ axios.get = async (url, config) => {
129
+ if (url === 'https://id.twitch.tv/oauth2/validate') {
130
+ validateCalls++;
131
+ const authHeader = config?.headers?.Authorization;
132
+ if (authHeader === 'Bearer new_refreshed_access_token') {
133
+ return { data: { client_id: 'mocked_client_id' } };
134
+ }
135
+ const err = new Error('Unauthorized');
136
+ err.response = { status: 401 };
137
+ throw err;
138
+ }
139
+ throw new Error(`Unexpected GET request to ${url}`);
140
+ };
141
+
142
+ const tokenScenario2 = await getOrRefreshAccessToken();
143
+ console.log('Scenario 2 Returned token:', tokenScenario2);
144
+ console.log('Scenario 2 Validate calls total:', validateCalls);
145
+ console.log('Scenario 2 Refresh calls total:', refreshCalls);
146
+
147
+ if (tokenScenario2 !== 'new_refreshed_access_token') {
148
+ console.error('FAIL: Expected token to remain new_refreshed_access_token, got:', tokenScenario2);
149
+ process.exit(1);
150
+ }
151
+ if (validateCalls !== 2) {
152
+ console.error('FAIL: Expected validate calls to increment to 2, got:', validateCalls);
153
+ process.exit(1);
154
+ }
155
+ if (refreshCalls !== 1) {
156
+ console.error('FAIL: Expected refresh calls to remain 1, got:', refreshCalls);
157
+ process.exit(1);
158
+ }
159
+
160
+ console.log('SUCCESS: Token refresh flow successfully verified and tested!');
161
+
162
+ // Clean up
163
+ sqliteDb.close();
164
+ if (fs.existsSync('./test_database.db')) {
165
+ fs.unlinkSync('./test_database.db');
166
+ }
167
+ }
168
+
169
+ runTest().catch(err => {
170
+ console.error('Test crashed:', err);
171
+ process.exit(1);
172
+ });