@terreno/rtk 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/authSlice.d.ts +13 -2
- package/dist/authSlice.d.ts.map +1 -1
- package/dist/authSlice.js +43 -5
- package/dist/authSlice.js.map +1 -1
- package/dist/authSlice.test.d.ts +2 -0
- package/dist/authSlice.test.d.ts.map +1 -0
- package/dist/authSlice.test.js +269 -0
- package/dist/authSlice.test.js.map +1 -0
- package/dist/test-preload.d.ts +2 -0
- package/dist/test-preload.d.ts.map +1 -0
- package/dist/test-preload.js +24 -0
- package/dist/test-preload.js.map +1 -0
- package/dist/useUpgradeCheck.d.ts +30 -1
- package/dist/useUpgradeCheck.d.ts.map +1 -1
- package/dist/useUpgradeCheck.js +67 -15
- package/dist/useUpgradeCheck.js.map +1 -1
- package/package.json +4 -3
- package/src/authSlice.test.ts +306 -0
- package/src/authSlice.ts +51 -9
- package/src/test-preload.ts +28 -0
- package/src/useUpgradeCheck.ts +91 -17
package/dist/useUpgradeCheck.js
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
import { useToast } from "@terreno/ui";
|
|
2
2
|
import Constants from "expo-constants";
|
|
3
|
-
import { useCallback, useEffect, useState } from "react";
|
|
4
|
-
import { Linking } from "react-native";
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { AppState, Linking } from "react-native";
|
|
5
5
|
import { useLazyGetVersionCheckQuery } from "./emptyApi";
|
|
6
6
|
import { IsWeb } from "./platform";
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Checks the running app build number against the backend's VersionConfig
|
|
9
|
+
* thresholds and returns the current upgrade status.
|
|
10
|
+
*
|
|
11
|
+
* - `isRequired` — the build is below the required threshold; the caller
|
|
12
|
+
* should block the UI (e.g. with `UpgradeRequiredScreen`).
|
|
13
|
+
* - `isWarning` — the build is below the warning threshold; the caller
|
|
14
|
+
* can render a dismissible `Banner` or similar prompt.
|
|
15
|
+
*
|
|
16
|
+
* By default a single check runs on mount. Pass `pollingIntervalMs` and/or
|
|
17
|
+
* `recheckOnForeground` to keep long-lived sessions up to date.
|
|
18
|
+
*
|
|
19
|
+
* @param options - Optional polling and foreground re-check configuration.
|
|
20
|
+
* @returns Current upgrade status, messages, and an `onUpdate` callback.
|
|
21
|
+
*/
|
|
22
|
+
export const useUpgradeCheck = (options) => {
|
|
23
|
+
const { pollingIntervalMs, recheckOnForeground = false } = options ?? {};
|
|
8
24
|
const [isRequired, setIsRequired] = useState(false);
|
|
25
|
+
const [isWarning, setIsWarning] = useState(false);
|
|
9
26
|
const [requiredMessage, setRequiredMessage] = useState();
|
|
10
|
-
const [updateUrl, setUpdateUrl] = useState();
|
|
11
27
|
const [warningMessage, setWarningMessage] = useState();
|
|
28
|
+
const [updateUrl, setUpdateUrl] = useState();
|
|
12
29
|
const toast = useToast();
|
|
13
30
|
const [triggerVersionCheck, result] = useLazyGetVersionCheckQuery();
|
|
14
31
|
const buildNumber = Constants.expoConfig?.extra?.buildNumber;
|
|
32
|
+
const appState = useRef(AppState.currentState);
|
|
33
|
+
const runCheck = useCallback(() => {
|
|
34
|
+
if (buildNumber === undefined || buildNumber === null) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
const platform = IsWeb ? "web" : "mobile";
|
|
38
|
+
void triggerVersionCheck({ platform, version: buildNumber });
|
|
39
|
+
}, [buildNumber, triggerVersionCheck]);
|
|
15
40
|
const onUpdate = useCallback(() => {
|
|
16
41
|
if (IsWeb) {
|
|
17
42
|
window.location.reload();
|
|
@@ -26,11 +51,39 @@ export const useUpgradeCheck = () => {
|
|
|
26
51
|
console.warn("useUpgradeCheck: no update URL available for mobile update");
|
|
27
52
|
}
|
|
28
53
|
}, [updateUrl]);
|
|
54
|
+
// Initial check on mount
|
|
55
|
+
useEffect(() => {
|
|
56
|
+
runCheck();
|
|
57
|
+
}, [runCheck]);
|
|
58
|
+
// Periodic re-check at the configured interval
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (!pollingIntervalMs) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const interval = setInterval(runCheck, pollingIntervalMs);
|
|
64
|
+
return () => clearInterval(interval);
|
|
65
|
+
}, [runCheck, pollingIntervalMs]);
|
|
66
|
+
// Re-check when app/tab returns to foreground
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (!recheckOnForeground) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const subscription = AppState.addEventListener("change", (nextAppState) => {
|
|
72
|
+
const wasBackground = /inactive|background/.test(appState.current);
|
|
73
|
+
const isNowActive = nextAppState === "active";
|
|
74
|
+
if (wasBackground && isNowActive) {
|
|
75
|
+
runCheck();
|
|
76
|
+
}
|
|
77
|
+
appState.current = nextAppState;
|
|
78
|
+
});
|
|
79
|
+
return () => subscription.remove();
|
|
80
|
+
}, [runCheck, recheckOnForeground]);
|
|
29
81
|
// Show warning toast in a separate effect. ToastProvider initializes its ref
|
|
30
82
|
// in useEffect, so we may need to retry when toast becomes available.
|
|
31
83
|
useEffect(() => {
|
|
32
|
-
if (!warningMessage)
|
|
84
|
+
if (!warningMessage) {
|
|
33
85
|
return;
|
|
86
|
+
}
|
|
34
87
|
const toastId = toast.warn(warningMessage, { persistent: true });
|
|
35
88
|
if (toastId) {
|
|
36
89
|
setWarningMessage(undefined);
|
|
@@ -39,14 +92,7 @@ export const useUpgradeCheck = () => {
|
|
|
39
92
|
console.warn("useUpgradeCheck: toast not yet available, will retry on next render");
|
|
40
93
|
}
|
|
41
94
|
}, [warningMessage, toast]);
|
|
42
|
-
|
|
43
|
-
if (buildNumber === undefined || buildNumber === null) {
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
const platform = IsWeb ? "web" : "mobile";
|
|
47
|
-
void triggerVersionCheck({ platform, version: buildNumber });
|
|
48
|
-
}, [buildNumber, triggerVersionCheck]);
|
|
49
|
-
// Process the version-check response: block on required, warn on warning
|
|
95
|
+
// Process version-check response — update warning/required state
|
|
50
96
|
useEffect(() => {
|
|
51
97
|
if (result.isError) {
|
|
52
98
|
console.debug("Version check failed, continuing normally", result.error);
|
|
@@ -59,15 +105,21 @@ export const useUpgradeCheck = () => {
|
|
|
59
105
|
if (status === "required") {
|
|
60
106
|
setIsRequired(true);
|
|
61
107
|
setRequiredMessage(message);
|
|
108
|
+
setIsWarning(false);
|
|
62
109
|
}
|
|
63
|
-
else if (status === "warning"
|
|
110
|
+
else if (status === "warning") {
|
|
111
|
+
setIsWarning(true);
|
|
64
112
|
setWarningMessage(message);
|
|
65
113
|
}
|
|
114
|
+
else {
|
|
115
|
+
setIsWarning(false);
|
|
116
|
+
setIsRequired(false);
|
|
117
|
+
}
|
|
66
118
|
if (responseUpdateUrl) {
|
|
67
119
|
setUpdateUrl(responseUpdateUrl);
|
|
68
120
|
}
|
|
69
121
|
}, [result.data, result.error, result.isError, result.isSuccess]);
|
|
70
122
|
const canUpdate = IsWeb || !!updateUrl;
|
|
71
|
-
return { canUpdate, isRequired, onUpdate, requiredMessage };
|
|
123
|
+
return { canUpdate, isRequired, isWarning, onUpdate, requiredMessage, warningMessage };
|
|
72
124
|
};
|
|
73
125
|
//# sourceMappingURL=useUpgradeCheck.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"useUpgradeCheck.js","sourceRoot":"","sources":["../src/useUpgradeCheck.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,QAAQ,EAAC,MAAM,aAAa,CAAC;AACrC,OAAO,SAAS,MAAM,gBAAgB,CAAC;AACvC,OAAO,EAAC,WAAW,EAAE,SAAS,EAAE,QAAQ,EAAC,MAAM,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"useUpgradeCheck.js","sourceRoot":"","sources":["../src/useUpgradeCheck.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"@better-auth/expo": "^1.2.8",
|
|
4
4
|
"@react-native-async-storage/async-storage": "2.2.0",
|
|
5
5
|
"@reduxjs/toolkit": "^2.11.1",
|
|
6
|
-
"@terreno/ui": "0.9.
|
|
6
|
+
"@terreno/ui": "0.9.2",
|
|
7
7
|
"async-mutex": "^0.5.0",
|
|
8
8
|
"axios": "^1.13.2",
|
|
9
9
|
"axios-retry": "^4.5.0",
|
|
@@ -57,8 +57,9 @@
|
|
|
57
57
|
"lint": "biome check .",
|
|
58
58
|
"lint:fix": "biome check --write .",
|
|
59
59
|
"lint:unsafefix": "biome check --write --unsafe .",
|
|
60
|
-
"test
|
|
60
|
+
"test": "bun test",
|
|
61
|
+
"test:ci": "bun test"
|
|
61
62
|
},
|
|
62
63
|
"types": "dist/index.d.ts",
|
|
63
|
-
"version": "0.9.
|
|
64
|
+
"version": "0.9.2"
|
|
64
65
|
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import {beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {configureStore} from "@reduxjs/toolkit";
|
|
3
|
+
import {createApi, fetchBaseQuery} from "@reduxjs/toolkit/query/react";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
type EmailLoginRequest,
|
|
7
|
+
generateAuthSlice,
|
|
8
|
+
selectCurrentUserId,
|
|
9
|
+
selectIsAuthenticating,
|
|
10
|
+
} from "./authSlice";
|
|
11
|
+
|
|
12
|
+
// Create a real RTK Query API with the endpoints that generateAuthSlice expects
|
|
13
|
+
const api = createApi({
|
|
14
|
+
baseQuery: fetchBaseQuery({baseUrl: "/"}),
|
|
15
|
+
endpoints: (builder) => ({
|
|
16
|
+
emailLogin: builder.mutation({
|
|
17
|
+
query: (body: EmailLoginRequest) => ({body, method: "POST", url: "auth/login"}),
|
|
18
|
+
}),
|
|
19
|
+
emailSignUp: builder.mutation({
|
|
20
|
+
query: (body: {email: string; password: string}) => ({
|
|
21
|
+
body,
|
|
22
|
+
method: "POST",
|
|
23
|
+
url: "auth/signup",
|
|
24
|
+
}),
|
|
25
|
+
}),
|
|
26
|
+
googleLogin: builder.mutation({
|
|
27
|
+
query: (body: {idToken: string}) => ({body, method: "POST", url: "auth/google"}),
|
|
28
|
+
}),
|
|
29
|
+
}),
|
|
30
|
+
reducerPath: "terreno-rtk",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
const createTestStore = () => {
|
|
34
|
+
const {authReducer, middleware, ...rest} = generateAuthSlice(
|
|
35
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock
|
|
36
|
+
api as any
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...rest,
|
|
41
|
+
store: configureStore({
|
|
42
|
+
middleware: (getDefault) =>
|
|
43
|
+
getDefault({serializableCheck: false}).concat(api.middleware, ...middleware),
|
|
44
|
+
reducer: {
|
|
45
|
+
[api.reducerPath]: api.reducer,
|
|
46
|
+
auth: authReducer,
|
|
47
|
+
},
|
|
48
|
+
}),
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
describe("generateAuthSlice", () => {
|
|
53
|
+
let store: ReturnType<typeof createTestStore>["store"];
|
|
54
|
+
let authSlice: ReturnType<typeof createTestStore>["authSlice"];
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
const result = createTestStore();
|
|
58
|
+
store = result.store;
|
|
59
|
+
authSlice = result.authSlice;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("initial state", () => {
|
|
63
|
+
it("has correct initial values", () => {
|
|
64
|
+
const state = store.getState().auth;
|
|
65
|
+
expect(state.userId).toBeNull();
|
|
66
|
+
expect(state.error).toBeNull();
|
|
67
|
+
expect(state.isAuthenticating).toBe(false);
|
|
68
|
+
expect(state.lastTokenRefreshTimestamp).toBeNull();
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("reducers", () => {
|
|
73
|
+
it("setUserId sets userId and clears isAuthenticating", () => {
|
|
74
|
+
store.dispatch(authSlice.actions.setUserId({userId: "user-123"}));
|
|
75
|
+
const state = store.getState().auth;
|
|
76
|
+
expect(state.userId).toBe("user-123");
|
|
77
|
+
expect(state.isAuthenticating).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("logout clears state", () => {
|
|
81
|
+
store.dispatch(authSlice.actions.setUserId({userId: "user-123"}));
|
|
82
|
+
store.dispatch(authSlice.actions.logout());
|
|
83
|
+
const state = store.getState().auth;
|
|
84
|
+
expect(state.userId).toBeNull();
|
|
85
|
+
expect(state.isAuthenticating).toBe(false);
|
|
86
|
+
expect(state.lastTokenRefreshTimestamp).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("tokenRefreshedSuccess sets timestamp", () => {
|
|
90
|
+
const before = Date.now();
|
|
91
|
+
store.dispatch(authSlice.actions.tokenRefreshedSuccess());
|
|
92
|
+
const state = store.getState().auth;
|
|
93
|
+
expect(state.lastTokenRefreshTimestamp).toBeGreaterThanOrEqual(before);
|
|
94
|
+
expect(state.lastTokenRefreshTimestamp).toBeLessThanOrEqual(Date.now());
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("emailLogin matchers", () => {
|
|
99
|
+
it("matchPending sets isAuthenticating and clears error", () => {
|
|
100
|
+
// Simulate a pending email login action
|
|
101
|
+
store.dispatch({
|
|
102
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
103
|
+
payload: undefined,
|
|
104
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
105
|
+
});
|
|
106
|
+
const state = store.getState().auth;
|
|
107
|
+
expect(state.isAuthenticating).toBe(true);
|
|
108
|
+
expect(state.error).toBeNull();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("matchFulfilled clears isAuthenticating", () => {
|
|
112
|
+
// Set authenticating first
|
|
113
|
+
store.dispatch({
|
|
114
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
115
|
+
payload: undefined,
|
|
116
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
117
|
+
});
|
|
118
|
+
expect(store.getState().auth.isAuthenticating).toBe(true);
|
|
119
|
+
|
|
120
|
+
// Then fulfill
|
|
121
|
+
store.dispatch({
|
|
122
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
123
|
+
payload: {token: "abc", userId: "user-1"},
|
|
124
|
+
type: "terreno-rtk/executeMutation/fulfilled",
|
|
125
|
+
});
|
|
126
|
+
expect(store.getState().auth.isAuthenticating).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("matchRejected sets error and clears isAuthenticating", () => {
|
|
130
|
+
// Set authenticating first
|
|
131
|
+
store.dispatch({
|
|
132
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
133
|
+
payload: undefined,
|
|
134
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Then reject with error
|
|
138
|
+
store.dispatch({
|
|
139
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
140
|
+
payload: {data: {message: "Invalid credentials"}},
|
|
141
|
+
type: "terreno-rtk/executeMutation/rejected",
|
|
142
|
+
});
|
|
143
|
+
const state = store.getState().auth;
|
|
144
|
+
expect(state.isAuthenticating).toBe(false);
|
|
145
|
+
expect(state.error).toBe("Invalid credentials");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("emailSignUp matchers", () => {
|
|
150
|
+
it("matchPending sets isAuthenticating and clears error", () => {
|
|
151
|
+
store.dispatch({
|
|
152
|
+
meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
|
|
153
|
+
payload: undefined,
|
|
154
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
155
|
+
});
|
|
156
|
+
const state = store.getState().auth;
|
|
157
|
+
expect(state.isAuthenticating).toBe(true);
|
|
158
|
+
expect(state.error).toBeNull();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("matchFulfilled clears isAuthenticating", () => {
|
|
162
|
+
store.dispatch({
|
|
163
|
+
meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
|
|
164
|
+
payload: undefined,
|
|
165
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
166
|
+
});
|
|
167
|
+
store.dispatch({
|
|
168
|
+
meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
|
|
169
|
+
payload: {token: "abc", userId: "user-1"},
|
|
170
|
+
type: "terreno-rtk/executeMutation/fulfilled",
|
|
171
|
+
});
|
|
172
|
+
expect(store.getState().auth.isAuthenticating).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("matchRejected sets error and clears isAuthenticating", () => {
|
|
176
|
+
store.dispatch({
|
|
177
|
+
meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
|
|
178
|
+
payload: undefined,
|
|
179
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
180
|
+
});
|
|
181
|
+
store.dispatch({
|
|
182
|
+
meta: {arg: {endpointName: "emailSignUp", type: "mutation"}, requestId: "test-1"},
|
|
183
|
+
payload: {data: {message: "Email already exists"}},
|
|
184
|
+
type: "terreno-rtk/executeMutation/rejected",
|
|
185
|
+
});
|
|
186
|
+
const state = store.getState().auth;
|
|
187
|
+
expect(state.isAuthenticating).toBe(false);
|
|
188
|
+
expect(state.error).toBe("Email already exists");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("googleLogin matchers", () => {
|
|
193
|
+
it("matchPending sets isAuthenticating and clears error", () => {
|
|
194
|
+
store.dispatch({
|
|
195
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
|
|
196
|
+
payload: undefined,
|
|
197
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
198
|
+
});
|
|
199
|
+
const state = store.getState().auth;
|
|
200
|
+
expect(state.isAuthenticating).toBe(true);
|
|
201
|
+
expect(state.error).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("matchPending clears stale error from previous attempt", () => {
|
|
205
|
+
// First: fail a login to set an error
|
|
206
|
+
store.dispatch({
|
|
207
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
208
|
+
payload: {data: {message: "Previous error"}},
|
|
209
|
+
type: "terreno-rtk/executeMutation/rejected",
|
|
210
|
+
});
|
|
211
|
+
expect(store.getState().auth.error).toBe("Previous error");
|
|
212
|
+
|
|
213
|
+
// Then: start a google login — should clear the error
|
|
214
|
+
store.dispatch({
|
|
215
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-2"},
|
|
216
|
+
payload: undefined,
|
|
217
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
218
|
+
});
|
|
219
|
+
expect(store.getState().auth.error).toBeNull();
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("matchFulfilled clears isAuthenticating", () => {
|
|
223
|
+
store.dispatch({
|
|
224
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
|
|
225
|
+
payload: undefined,
|
|
226
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
227
|
+
});
|
|
228
|
+
store.dispatch({
|
|
229
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
|
|
230
|
+
payload: {token: "abc", userId: "user-1"},
|
|
231
|
+
type: "terreno-rtk/executeMutation/fulfilled",
|
|
232
|
+
});
|
|
233
|
+
expect(store.getState().auth.isAuthenticating).toBe(false);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("matchRejected sets error and clears isAuthenticating", () => {
|
|
237
|
+
store.dispatch({
|
|
238
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
|
|
239
|
+
payload: undefined,
|
|
240
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
241
|
+
});
|
|
242
|
+
store.dispatch({
|
|
243
|
+
meta: {arg: {endpointName: "googleLogin", type: "mutation"}, requestId: "test-1"},
|
|
244
|
+
payload: {data: {message: "Google auth failed"}},
|
|
245
|
+
type: "terreno-rtk/executeMutation/rejected",
|
|
246
|
+
});
|
|
247
|
+
const state = store.getState().auth;
|
|
248
|
+
expect(state.isAuthenticating).toBe(false);
|
|
249
|
+
expect(state.error).toBe("Google auth failed");
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe("persist/REHYDRATE", () => {
|
|
254
|
+
it("resets isAuthenticating to false", () => {
|
|
255
|
+
// Set authenticating
|
|
256
|
+
store.dispatch({
|
|
257
|
+
meta: {arg: {endpointName: "emailLogin", type: "mutation"}, requestId: "test-1"},
|
|
258
|
+
payload: undefined,
|
|
259
|
+
type: "terreno-rtk/executeMutation/pending",
|
|
260
|
+
});
|
|
261
|
+
expect(store.getState().auth.isAuthenticating).toBe(true);
|
|
262
|
+
|
|
263
|
+
// Simulate rehydration
|
|
264
|
+
store.dispatch({type: "persist/REHYDRATE"});
|
|
265
|
+
expect(store.getState().auth.isAuthenticating).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
describe("selectors", () => {
|
|
271
|
+
it("selectCurrentUserId returns userId", () => {
|
|
272
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock state
|
|
273
|
+
const state = {auth: {userId: "user-123"}} as any;
|
|
274
|
+
expect(selectCurrentUserId(state)).toBe("user-123");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it("selectCurrentUserId returns undefined when no auth state", () => {
|
|
278
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock state
|
|
279
|
+
expect(selectCurrentUserId({} as any)).toBeUndefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("selectIsAuthenticating returns isAuthenticating", () => {
|
|
283
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock state
|
|
284
|
+
const state = {auth: {isAuthenticating: true}} as any;
|
|
285
|
+
expect(selectIsAuthenticating(state)).toBe(true);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("selectIsAuthenticating defaults to false", () => {
|
|
289
|
+
// biome-ignore lint/suspicious/noExplicitAny: Test mock state
|
|
290
|
+
expect(selectIsAuthenticating({} as any)).toBe(false);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("EmailLoginRequest type", () => {
|
|
295
|
+
it("accepts email-based login", () => {
|
|
296
|
+
const request: EmailLoginRequest = {email: "test@example.com", password: "pass"};
|
|
297
|
+
expect(request.email).toBe("test@example.com");
|
|
298
|
+
expect(request.password).toBe("pass");
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("accepts username-based login", () => {
|
|
302
|
+
const request: EmailLoginRequest = {password: "pass", username: "testuser"};
|
|
303
|
+
expect(request.username).toBe("testuser");
|
|
304
|
+
expect(request.password).toBe("pass");
|
|
305
|
+
});
|
|
306
|
+
});
|
package/src/authSlice.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {IsWeb} from "./platform";
|
|
|
12
12
|
type AuthState = {
|
|
13
13
|
userId: string | null;
|
|
14
14
|
error: string | null;
|
|
15
|
+
isAuthenticating: boolean;
|
|
15
16
|
lastTokenRefreshTimestamp: number | null;
|
|
16
17
|
};
|
|
17
18
|
|
|
@@ -23,10 +24,9 @@ export interface UserResponse {
|
|
|
23
24
|
};
|
|
24
25
|
}
|
|
25
26
|
|
|
26
|
-
export
|
|
27
|
-
email: string;
|
|
28
|
-
password: string;
|
|
29
|
-
}
|
|
27
|
+
export type EmailLoginRequest =
|
|
28
|
+
| {email: string; username?: never; password: string}
|
|
29
|
+
| {username: string; email?: never; password: string};
|
|
30
30
|
|
|
31
31
|
export interface EmailSignupRequest {
|
|
32
32
|
email: string;
|
|
@@ -67,8 +67,8 @@ export function generateProfileEndpoints(
|
|
|
67
67
|
emailLogin: builder.mutation<UserResponse, EmailLoginRequest>({
|
|
68
68
|
extraOptions: {maxRetries: 0},
|
|
69
69
|
invalidatesTags: [path],
|
|
70
|
-
query: ({email, password}) => ({
|
|
71
|
-
body: {email, password},
|
|
70
|
+
query: ({email, username, password}) => ({
|
|
71
|
+
body: {email, password, username},
|
|
72
72
|
method: "POST",
|
|
73
73
|
url: "auth/login",
|
|
74
74
|
}),
|
|
@@ -105,7 +105,14 @@ export function generateProfileEndpoints(
|
|
|
105
105
|
export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
|
|
106
106
|
const authSlice = createSlice({
|
|
107
107
|
extraReducers: (builder) => {
|
|
108
|
-
|
|
108
|
+
// Reset isAuthenticating on rehydration in case the app was killed mid-login
|
|
109
|
+
builder.addCase("persist/REHYDRATE", (state) => {
|
|
110
|
+
state.isAuthenticating = false;
|
|
111
|
+
});
|
|
112
|
+
builder.addMatcher(api.endpoints.emailLogin.matchFulfilled, (state) => {
|
|
113
|
+
// Note: isAuthenticating is normally cleared by setUserId after the listener
|
|
114
|
+
// middleware stores the token. This is a safety fallback in case the listener fails.
|
|
115
|
+
state.isAuthenticating = false;
|
|
109
116
|
console.debug("Login success");
|
|
110
117
|
});
|
|
111
118
|
builder.addMatcher(
|
|
@@ -113,14 +120,18 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
|
|
|
113
120
|
// biome-ignore lint/suspicious/noExplicitAny: Generic
|
|
114
121
|
(state, action: PayloadAction<{data: any}>) => {
|
|
115
122
|
state.error = action.payload?.data?.message;
|
|
123
|
+
state.isAuthenticating = false;
|
|
116
124
|
console.debug("Login rejected", action.payload?.data?.message);
|
|
117
125
|
}
|
|
118
126
|
);
|
|
119
127
|
builder.addMatcher(api.endpoints.emailLogin.matchPending, (state) => {
|
|
120
128
|
state.error = null;
|
|
129
|
+
state.isAuthenticating = true;
|
|
121
130
|
console.debug("Login pending");
|
|
122
131
|
});
|
|
123
|
-
builder.addMatcher(api.endpoints.emailSignUp.matchFulfilled, () => {
|
|
132
|
+
builder.addMatcher(api.endpoints.emailSignUp.matchFulfilled, (state) => {
|
|
133
|
+
// Safety fallback: clear isAuthenticating in case the listener middleware fails.
|
|
134
|
+
state.isAuthenticating = false;
|
|
124
135
|
console.debug("Signup success");
|
|
125
136
|
});
|
|
126
137
|
builder.addMatcher(
|
|
@@ -128,23 +139,48 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
|
|
|
128
139
|
// biome-ignore lint/suspicious/noExplicitAny: Generic
|
|
129
140
|
(state, action: PayloadAction<{data: any}>) => {
|
|
130
141
|
state.error = action.payload?.data?.message;
|
|
142
|
+
state.isAuthenticating = false;
|
|
131
143
|
console.debug("Signup rejected", action.payload);
|
|
132
144
|
}
|
|
133
145
|
);
|
|
134
146
|
builder.addMatcher(api.endpoints.emailSignUp.matchPending, (state) => {
|
|
135
147
|
state.error = null;
|
|
148
|
+
state.isAuthenticating = true;
|
|
136
149
|
console.debug("Signup pending");
|
|
137
150
|
});
|
|
151
|
+
builder.addMatcher(api.endpoints.googleLogin.matchPending, (state) => {
|
|
152
|
+
state.error = null;
|
|
153
|
+
state.isAuthenticating = true;
|
|
154
|
+
});
|
|
155
|
+
builder.addMatcher(api.endpoints.googleLogin.matchFulfilled, (state) => {
|
|
156
|
+
// Safety fallback: clear isAuthenticating in case the listener middleware fails.
|
|
157
|
+
state.isAuthenticating = false;
|
|
158
|
+
});
|
|
159
|
+
builder.addMatcher(
|
|
160
|
+
api.endpoints.googleLogin.matchRejected,
|
|
161
|
+
// biome-ignore lint/suspicious/noExplicitAny: Generic
|
|
162
|
+
(state, action: PayloadAction<{data: any}>) => {
|
|
163
|
+
state.error = action.payload?.data?.message;
|
|
164
|
+
state.isAuthenticating = false;
|
|
165
|
+
}
|
|
166
|
+
);
|
|
138
167
|
},
|
|
139
|
-
initialState: {
|
|
168
|
+
initialState: {
|
|
169
|
+
error: null,
|
|
170
|
+
isAuthenticating: false,
|
|
171
|
+
lastTokenRefreshTimestamp: null,
|
|
172
|
+
userId: null,
|
|
173
|
+
} as AuthState,
|
|
140
174
|
name: "auth",
|
|
141
175
|
reducers: {
|
|
142
176
|
logout: (state) => {
|
|
143
177
|
state.userId = null;
|
|
178
|
+
state.isAuthenticating = false;
|
|
144
179
|
state.lastTokenRefreshTimestamp = null;
|
|
145
180
|
},
|
|
146
181
|
setUserId: (state, {payload: {userId}}: PayloadAction<{userId: string}>) => {
|
|
147
182
|
state.userId = userId;
|
|
183
|
+
state.isAuthenticating = false;
|
|
148
184
|
},
|
|
149
185
|
tokenRefreshedSuccess: (state) => {
|
|
150
186
|
state.lastTokenRefreshTimestamp = Date.now();
|
|
@@ -249,6 +285,8 @@ export const generateAuthSlice = (api: Api<any, any, any, any, any>) => {
|
|
|
249
285
|
export const selectCurrentUserId = (state: RootState): string | undefined => state.auth?.userId;
|
|
250
286
|
export const selectLastTokenRefreshTimestamp = (state: RootState): number | null =>
|
|
251
287
|
state.auth?.lastTokenRefreshTimestamp;
|
|
288
|
+
export const selectIsAuthenticating = (state: RootState): boolean =>
|
|
289
|
+
state.auth?.isAuthenticating ?? false;
|
|
252
290
|
|
|
253
291
|
export const useSelectCurrentUserId = (): string | undefined => {
|
|
254
292
|
return useSelector((state: RootState): string | undefined => {
|
|
@@ -256,6 +294,10 @@ export const useSelectCurrentUserId = (): string | undefined => {
|
|
|
256
294
|
});
|
|
257
295
|
};
|
|
258
296
|
|
|
297
|
+
export const useSelectIsAuthenticating = (): boolean => {
|
|
298
|
+
return useSelector((state: RootState): boolean => state.auth?.isAuthenticating ?? false);
|
|
299
|
+
};
|
|
300
|
+
|
|
259
301
|
export async function getAuthToken(): Promise<string | null> {
|
|
260
302
|
let token: string | null;
|
|
261
303
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {mock} from "bun:test";
|
|
2
|
+
|
|
3
|
+
mock.module("react-native", () => ({
|
|
4
|
+
Platform: {OS: "web"},
|
|
5
|
+
StyleSheet: {create: (s: unknown) => s},
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
mock.module("expo-secure-store", () => ({
|
|
9
|
+
deleteItemAsync: async () => {},
|
|
10
|
+
getItemAsync: async () => null,
|
|
11
|
+
setItemAsync: async () => {},
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
mock.module("@react-native-async-storage/async-storage", () => ({
|
|
15
|
+
default: {
|
|
16
|
+
getItem: async () => null,
|
|
17
|
+
removeItem: async () => {},
|
|
18
|
+
setItem: async () => {},
|
|
19
|
+
},
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
mock.module("expo-constants", () => ({
|
|
23
|
+
default: {expoConfig: {extra: {}}},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("expo-network", () => ({
|
|
27
|
+
getNetworkStateAsync: async () => ({isConnected: true}),
|
|
28
|
+
}));
|