Skip to content

Commit 5fb19fc

Browse files
demo: Update TanStackDB Demo
1 parent 317c748 commit 5fb19fc

File tree

10 files changed

+681
-137
lines changed

10 files changed

+681
-137
lines changed
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { FC, ReactNode } from 'react';
2+
3+
/**
4+
* Mock GuardBySync that always renders children immediately.
5+
* Bypasses the sync check for testing purposes.
6+
*/
7+
export const GuardBySync: FC<{ children: ReactNode; priority?: number }> = ({ children }) => {
8+
// Always render children - skip the sync check
9+
return <>{children}</>;
10+
};
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import {
2+
AbstractPowerSyncDatabase,
3+
BaseObserver,
4+
PowerSyncBackendConnector,
5+
type PowerSyncCredentials
6+
} from '@powersync/web';
7+
8+
// Mock user ID for testing
9+
export const MOCK_USER_ID = 'test-user-123';
10+
11+
export type SupabaseConfig = {
12+
supabaseUrl: string;
13+
supabaseAnonKey: string;
14+
powersyncUrl: string;
15+
};
16+
17+
// Mock Session type matching Supabase's Session
18+
type MockSession = {
19+
access_token: string;
20+
refresh_token: string;
21+
expires_in: number;
22+
expires_at: number;
23+
token_type: string;
24+
user: {
25+
id: string;
26+
email: string;
27+
aud: string;
28+
role: string;
29+
created_at: string;
30+
};
31+
};
32+
33+
export type SupabaseConnectorListener = {
34+
initialized: () => void;
35+
sessionStarted: (session: MockSession) => void;
36+
};
37+
38+
/**
39+
* Mock SupabaseConnector for testing.
40+
* Simulates an authenticated session without requiring actual Supabase credentials.
41+
*/
42+
export class SupabaseConnector extends BaseObserver<SupabaseConnectorListener> implements PowerSyncBackendConnector {
43+
readonly client: MockSupabaseClient;
44+
readonly config: SupabaseConfig;
45+
46+
ready: boolean;
47+
currentSession: MockSession | null;
48+
49+
constructor() {
50+
super();
51+
this.config = {
52+
supabaseUrl: 'https://mock.supabase.test',
53+
powersyncUrl: 'https://mock.powersync.test',
54+
supabaseAnonKey: 'mock-anon-key'
55+
};
56+
57+
this.client = new MockSupabaseClient();
58+
59+
// Pre-authenticated session
60+
this.currentSession = createMockSession();
61+
this.ready = false;
62+
}
63+
64+
async init() {
65+
if (this.ready) {
66+
return;
67+
}
68+
69+
// Simulate session being loaded
70+
this.ready = true;
71+
this.iterateListeners((cb) => cb.initialized?.());
72+
73+
// Trigger session started since we're pre-authenticated
74+
if (this.currentSession) {
75+
this.iterateListeners((cb) => cb.sessionStarted?.(this.currentSession!));
76+
}
77+
}
78+
79+
async login(_username: string, _password: string) {
80+
// Mock login - always succeeds
81+
this.currentSession = createMockSession();
82+
this.updateSession(this.currentSession);
83+
}
84+
85+
async fetchCredentials(): Promise<PowerSyncCredentials> {
86+
// Return mock credentials
87+
return {
88+
endpoint: this.config.powersyncUrl,
89+
token: this.currentSession?.access_token ?? 'mock-token'
90+
};
91+
}
92+
93+
async uploadData(_database: AbstractPowerSyncDatabase): Promise<void> {
94+
// Mock upload - do nothing
95+
}
96+
97+
updateSession(session: MockSession | null) {
98+
this.currentSession = session;
99+
if (session) {
100+
this.iterateListeners((cb) => cb.sessionStarted?.(session));
101+
}
102+
}
103+
}
104+
105+
/**
106+
* Creates a mock authenticated session
107+
*/
108+
function createMockSession(): MockSession {
109+
return {
110+
access_token: 'mock-access-token',
111+
refresh_token: 'mock-refresh-token',
112+
expires_in: 3600,
113+
expires_at: Math.floor(Date.now() / 1000) + 3600,
114+
token_type: 'bearer',
115+
user: {
116+
id: MOCK_USER_ID,
117+
email: 'test@example.com',
118+
aud: 'authenticated',
119+
role: 'authenticated',
120+
created_at: new Date().toISOString()
121+
}
122+
};
123+
}
124+
125+
/**
126+
* Mock Supabase client for testing
127+
*/
128+
class MockSupabaseClient {
129+
auth = {
130+
getSession: async () => ({
131+
data: { session: createMockSession() },
132+
error: null
133+
}),
134+
signInWithPassword: async (_credentials: { email: string; password: string }) => ({
135+
data: { session: createMockSession() },
136+
error: null
137+
}),
138+
signOut: async () => ({ error: null }),
139+
onAuthStateChange: (_callback: (event: string, session: MockSession | null) => void) => {
140+
return { data: { subscription: { unsubscribe: () => {} } } };
141+
}
142+
};
143+
144+
from(_table: string) {
145+
return {
146+
upsert: async (_record: unknown) => ({ error: null }),
147+
update: (_data: unknown) => ({
148+
eq: async (_column: string, _value: unknown) => ({ error: null })
149+
}),
150+
delete: () => ({
151+
eq: async (_column: string, _value: unknown) => ({ error: null })
152+
})
153+
};
154+
}
155+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Re-export MOCK_USER_ID from the mock
2+
export { MOCK_USER_ID } from './mocks/SupabaseConnector';
3+
4+
/**
5+
* Sets up the test DOM structure matching the app's index.html
6+
*/
7+
export function setupTestDOM() {
8+
document.body.innerHTML = `
9+
<div id="app"></div>
10+
`;
11+
return document.getElementById('app')!;
12+
}
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import { screen, waitFor } from '@testing-library/react';
2+
import { act } from 'react';
3+
import { Root, createRoot } from 'react-dom/client';
4+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
5+
import { App } from '../src/app/App';
6+
import { MOCK_USER_ID, setupTestDOM } from './test-setup';
7+
8+
// Get access to the PowerSync database instance exposed on window
9+
declare global {
10+
interface Window {
11+
_powersync: import('@powersync/web').PowerSyncDatabase;
12+
}
13+
}
14+
15+
describe('TodoLists E2E', () => {
16+
let root: Root | null = null;
17+
let container: HTMLElement;
18+
19+
beforeEach(() => {
20+
// Set up DOM
21+
container = setupTestDOM();
22+
});
23+
24+
afterEach(async () => {
25+
// Cleanup
26+
if (root) {
27+
root.unmount();
28+
root = null;
29+
}
30+
31+
// Clean up the PowerSync database if it exists
32+
if (window._powersync) {
33+
try {
34+
await window._powersync.disconnectAndClear();
35+
} catch (e) {
36+
// Ignore errors during cleanup
37+
}
38+
}
39+
});
40+
41+
/**
42+
* Helper to render app and wait for the Todo Lists screen to appear
43+
*/
44+
async function renderAppAndWaitForTodoListsScreen() {
45+
await act(async () => {
46+
root = createRoot(container);
47+
root.render(<App />);
48+
});
49+
50+
// Wait for PowerSync to be initialized
51+
await waitFor(
52+
() => {
53+
expect(window._powersync).toBeDefined();
54+
},
55+
{ timeout: 10000 }
56+
);
57+
58+
// Wait for the Todo Lists screen to appear (app auto-navigates when authenticated)
59+
await waitFor(
60+
() => {
61+
expect(screen.getByText('Todo Lists')).toBeTruthy();
62+
},
63+
{ timeout: 10000 }
64+
);
65+
}
66+
67+
/**
68+
* Helper to insert a list and wait for it to appear
69+
*/
70+
async function insertList(name: string) {
71+
const listId = crypto.randomUUID();
72+
73+
await act(async () => {
74+
await window._powersync.execute(`INSERT INTO lists (id, name, owner_id, created_at) VALUES (?, ?, ?, ?)`, [
75+
listId,
76+
name,
77+
MOCK_USER_ID,
78+
new Date().toISOString()
79+
]);
80+
});
81+
82+
return listId;
83+
}
84+
85+
/**
86+
* Helper to insert a todo
87+
*/
88+
async function insertTodo(listId: string, description: string, completed: boolean = false) {
89+
const todoId = crypto.randomUUID();
90+
91+
await act(async () => {
92+
await window._powersync.execute(
93+
`INSERT INTO todos (id, list_id, description, created_by, created_at, completed, completed_at, completed_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
94+
[
95+
todoId,
96+
listId,
97+
description,
98+
MOCK_USER_ID,
99+
new Date().toISOString(),
100+
completed ? 1 : 0,
101+
completed ? new Date().toISOString() : null,
102+
completed ? MOCK_USER_ID : null
103+
]
104+
);
105+
});
106+
107+
return todoId;
108+
}
109+
110+
it('should load the app and show the Todo Lists screen', async () => {
111+
await renderAppAndWaitForTodoListsScreen();
112+
113+
// Verify we're on the Todo Lists page
114+
expect(screen.getByText('Todo Lists')).toBeTruthy();
115+
});
116+
117+
it('should display a list widget after inserting a list via PowerSync SQL', async () => {
118+
await renderAppAndWaitForTodoListsScreen();
119+
120+
const listName = 'My Shopping List';
121+
await insertList(listName);
122+
123+
// Wait for the list widget to render with our list name
124+
await waitFor(
125+
() => {
126+
expect(screen.getByText(listName)).toBeTruthy();
127+
},
128+
{ timeout: 10000 }
129+
);
130+
131+
// Verify action buttons are present
132+
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
133+
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
134+
});
135+
136+
it('should display multiple list widgets after inserting multiple lists', async () => {
137+
await renderAppAndWaitForTodoListsScreen();
138+
139+
// Insert multiple lists
140+
await insertList('Groceries');
141+
await insertList('Work Tasks');
142+
await insertList('Personal Goals');
143+
144+
// Wait for all list widgets to render
145+
await waitFor(
146+
() => {
147+
expect(screen.getByText('Groceries')).toBeTruthy();
148+
expect(screen.getByText('Work Tasks')).toBeTruthy();
149+
expect(screen.getByText('Personal Goals')).toBeTruthy();
150+
},
151+
{ timeout: 10000 }
152+
);
153+
154+
// Verify we have 3 delete buttons (one per list)
155+
const deleteButtons = screen.getAllByRole('button', { name: /delete/i });
156+
expect(deleteButtons).toHaveLength(3);
157+
});
158+
159+
it('should display list with correct todo counts (pending and completed)', async () => {
160+
await renderAppAndWaitForTodoListsScreen();
161+
162+
const listName = 'My Task List';
163+
const listId = await insertList(listName);
164+
165+
// Insert todos - 2 incomplete, 1 completed
166+
await insertTodo(listId, 'Buy groceries', false);
167+
await insertTodo(listId, 'Call mom', false);
168+
await insertTodo(listId, 'Finish report', true);
169+
170+
// Wait for the list widget to render with correct stats
171+
await waitFor(
172+
() => {
173+
expect(screen.getByText(listName)).toBeTruthy();
174+
// Should show "2 pending, 1 completed" in the description
175+
expect(screen.getByText(/2 pending/i)).toBeTruthy();
176+
expect(screen.getByText(/1 completed/i)).toBeTruthy();
177+
},
178+
{ timeout: 10000 }
179+
);
180+
});
181+
182+
it('should render list widgets with delete and navigate action buttons', async () => {
183+
await renderAppAndWaitForTodoListsScreen();
184+
185+
const listName = 'Test List';
186+
await insertList(listName);
187+
188+
// Wait for the list widget with action buttons
189+
await waitFor(
190+
() => {
191+
expect(screen.getByText(listName)).toBeTruthy();
192+
expect(screen.getByRole('button', { name: /delete/i })).toBeTruthy();
193+
expect(screen.getByRole('button', { name: /proceed/i })).toBeTruthy();
194+
},
195+
{ timeout: 10000 }
196+
);
197+
});
198+
199+
it('should display the floating action button to add new lists', async () => {
200+
await renderAppAndWaitForTodoListsScreen();
201+
202+
// The FAB should be present - find by class
203+
const fab = document.querySelector('.MuiFab-root');
204+
expect(fab).not.toBeNull();
205+
});
206+
});

0 commit comments

Comments
 (0)