mirror of
https://github.com/geoffsee/open-gsio.git
synced 2025-09-08 22:56:46 +00:00
adds more tests
This commit is contained in:

committed by
Geoff Seemueller

parent
33baf588b6
commit
ebbfd4d31a
@@ -19,9 +19,9 @@
|
||||
"tail:analytics-service": "wrangler tail -c workers/analytics/wrangler-analytics.toml",
|
||||
"tail:session-proxy": "wrangler tail -c workers/session-proxy/wrangler-session-proxy.toml --env production",
|
||||
"openai:local": "./scripts/start_inference_server.sh",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
"test": "NODE_OPTIONS=--no-experimental-fetch vitest run",
|
||||
"test:watch": "NODE_OPTIONS=--no-experimental-fetch vitest",
|
||||
"test:coverage": "NODE_OPTIONS=--no-experimental-fetch vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
|
@@ -1,272 +1,229 @@
|
||||
import { applySnapshot, flow, Instance, types } from "mobx-state-tree";
|
||||
// ---------------------------
|
||||
// stores/MessagesStore.ts
|
||||
// ---------------------------
|
||||
import { Instance, types } from "mobx-state-tree";
|
||||
import Message from "../models/Message";
|
||||
|
||||
export const MessagesStore = types
|
||||
.model("MessagesStore", {
|
||||
items: types.optional(types.array(Message), []),
|
||||
})
|
||||
.actions((self) => ({
|
||||
add(message: Instance<typeof Message>) {
|
||||
self.items.push(message);
|
||||
},
|
||||
updateLast(content: string) {
|
||||
if (self.items.length) {
|
||||
self.items[self.items.length - 1].content = content;
|
||||
}
|
||||
},
|
||||
appendLast(content: string) {
|
||||
if (self.items.length) {
|
||||
self.items[self.items.length - 1].content += content;
|
||||
}
|
||||
},
|
||||
removeAfter(index: number) {
|
||||
if (index >= 0 && index < self.items.length) {
|
||||
self.items.splice(index + 1);
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
self.items.clear();
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IMessagesStore extends Instance<typeof MessagesStore> {}
|
||||
|
||||
// ---------------------------
|
||||
// stores/UIStore.ts
|
||||
// ---------------------------
|
||||
import { Instance, types } from "mobx-state-tree";
|
||||
|
||||
export const UIStore = types
|
||||
.model("UIStore", {
|
||||
input: types.optional(types.string, ""),
|
||||
isLoading: types.optional(types.boolean, false),
|
||||
})
|
||||
.actions((self) => ({
|
||||
setInput(value: string) {
|
||||
self.input = value;
|
||||
},
|
||||
setIsLoading(value: boolean) {
|
||||
self.isLoading = value;
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IUIStore extends Instance<typeof UIStore> {}
|
||||
|
||||
// ---------------------------
|
||||
// stores/ModelStore.ts
|
||||
// ---------------------------
|
||||
import { Instance, types } from "mobx-state-tree";
|
||||
|
||||
export const ModelStore = types
|
||||
.model("ModelStore", {
|
||||
model: types.optional(
|
||||
types.string,
|
||||
"meta-llama/llama-4-scout-17b-16e-instruct",
|
||||
),
|
||||
imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"),
|
||||
supportedModels: types.optional(types.array(types.string), []),
|
||||
})
|
||||
.actions((self) => ({
|
||||
setModel(value: string) {
|
||||
self.model = value;
|
||||
try {
|
||||
localStorage.setItem("recentModel", value);
|
||||
} catch (_) {}
|
||||
},
|
||||
setImageModel(value: string) {
|
||||
self.imageModel = value;
|
||||
},
|
||||
setSupportedModels(list: string[]) {
|
||||
self.supportedModels = list;
|
||||
if (!list.includes(self.model)) {
|
||||
// fall back to last entry (arbitrary but predictable)
|
||||
self.model = list[list.length - 1] ?? self.model;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
export interface IModelStore extends Instance<typeof ModelStore> {}
|
||||
|
||||
// ---------------------------
|
||||
// stores/StreamStore.ts
|
||||
// Handles networking + SSE lifecycle.
|
||||
// Depends on MessagesStore, UIStore, and ModelStore via composition.
|
||||
// ---------------------------
|
||||
import {
|
||||
getParent,
|
||||
Instance,
|
||||
flow,
|
||||
types,
|
||||
} from "mobx-state-tree";
|
||||
import type { IMessagesStore } from "./MessagesStore";
|
||||
import type { IUIStore } from "./UIStore";
|
||||
import type { IModelStore } from "./ModelStore";
|
||||
import UserOptionsStore from "./UserOptionsStore";
|
||||
import Message from "../models/Message";
|
||||
|
||||
const ClientChatStore = types
|
||||
.model("ClientChatStore", {
|
||||
messages: types.optional(types.array(Message), []),
|
||||
input: types.optional(types.string, ""),
|
||||
isLoading: types.optional(types.boolean, false),
|
||||
model: types.optional(types.string, "meta-llama/llama-4-scout-17b-16e-instruct"),
|
||||
imageModel: types.optional(types.string, "black-forest-labs/flux-1.1-pro"),
|
||||
supportedModels: types.optional(types.array(types.string), [])
|
||||
})
|
||||
.actions((self) => ({
|
||||
cleanup() {
|
||||
if (self.eventSource) {
|
||||
self.eventSource.close();
|
||||
self.eventSource = null;
|
||||
interface RootDeps extends IMessagesStore, IUIStore, IModelStore {}
|
||||
|
||||
export const StreamStore = types
|
||||
.model("StreamStore", {})
|
||||
.volatile(() => ({
|
||||
eventSource: null as EventSource | null,
|
||||
}))
|
||||
.actions((self) => {
|
||||
// helpers
|
||||
const root = getParent<RootDeps>(self);
|
||||
|
||||
function cleanup() {
|
||||
if (self.eventSource) {
|
||||
self.eventSource.close();
|
||||
self.eventSource = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
setSupportedModels(modelsList: string[]) {
|
||||
self.supportedModels = modelsList;
|
||||
if(!modelsList.includes(self.model)) {
|
||||
self.model = modelsList.pop()
|
||||
}
|
||||
},
|
||||
sendMessage: flow(function* () {
|
||||
if (!self.input.trim() || self.isLoading) return;
|
||||
|
||||
self.cleanup();
|
||||
const sendMessage = flow(function* () {
|
||||
if (!root.input.trim() || root.isLoading) return;
|
||||
cleanup();
|
||||
yield UserOptionsStore.setFollowModeEnabled(true);
|
||||
root.setIsLoading(true);
|
||||
|
||||
yield self.setFollowModeEnabled(true);
|
||||
self.setIsLoading(true);
|
||||
|
||||
const userMessage = Message.create({
|
||||
content: self.input,
|
||||
role: "user" as const,
|
||||
});
|
||||
self.addMessage(userMessage);
|
||||
self.setInput("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
messages: self.messages.slice(),
|
||||
model: self.model,
|
||||
};
|
||||
|
||||
yield new Promise((resolve) => setTimeout(resolve, 500));
|
||||
self.addMessage(Message.create({ content: "", role: "assistant" }));
|
||||
|
||||
const response = yield fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
const userMessage = Message.create({
|
||||
content: root.input,
|
||||
role: "user" as const,
|
||||
});
|
||||
if (response.status === 429) {
|
||||
self.updateLastMessage(
|
||||
`Too many requests in the given time. Please wait a few moments and try again.`,
|
||||
);
|
||||
self.cleanup();
|
||||
return;
|
||||
}
|
||||
if (response.status > 200) {
|
||||
self.updateLastMessage(`Error! Something went wrong, try again.`);
|
||||
self.cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const { streamUrl } = yield response.json();
|
||||
self.eventSource = new EventSource(streamUrl);
|
||||
|
||||
self.eventSource.onmessage = async (event) => {
|
||||
try {
|
||||
const dataString = event.data;
|
||||
const parsedData = JSON.parse(dataString);
|
||||
|
||||
if (parsedData.type === "error") {
|
||||
self.updateLastMessage(`${parsedData.error}`);
|
||||
self.cleanup();
|
||||
self.setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
parsedData.type === "chat" &&
|
||||
parsedData.data.choices[0]?.finish_reason === "stop"
|
||||
) {
|
||||
self.appendToLastMessage(
|
||||
parsedData.data.choices[0]?.delta?.content || "",
|
||||
);
|
||||
self.cleanup();
|
||||
self.setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedData.type === "chat") {
|
||||
self.appendToLastMessage(
|
||||
parsedData.data.choices[0]?.delta?.content || "",
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing stream:", error);
|
||||
}
|
||||
};
|
||||
|
||||
self.eventSource.onerror = (e) => {
|
||||
self.cleanup();
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in sendMessage:", error);
|
||||
if (
|
||||
!self.messages.length ||
|
||||
self.messages[self.messages.length - 1].role !== "assistant"
|
||||
) {
|
||||
self.addMessage({
|
||||
content: "Sorry, there was an error.",
|
||||
role: "assistant",
|
||||
});
|
||||
} else {
|
||||
self.updateLastMessage("Sorry, there was an error.");
|
||||
}
|
||||
self.cleanup();
|
||||
self.setIsLoading(false);
|
||||
} finally {
|
||||
}
|
||||
}),
|
||||
setFollowModeEnabled: flow(function* (isEnabled: boolean) {
|
||||
yield UserOptionsStore.setFollowModeEnabled(isEnabled);
|
||||
}),
|
||||
setInput(value: string) {
|
||||
self.input = value;
|
||||
},
|
||||
setModel(value: string) {
|
||||
self.model = value;
|
||||
try {
|
||||
localStorage.setItem("recentModel", value);
|
||||
} catch (error) {}
|
||||
},
|
||||
setImageModel(value: string) {
|
||||
self.imageModel = value;
|
||||
},
|
||||
addMessage(message: Instance<typeof Message>) {
|
||||
self.messages.push(message);
|
||||
},
|
||||
editMessage: flow(function* (index: number, content: string) {
|
||||
yield self.setFollowModeEnabled(true);
|
||||
if (index >= 0 && index < self.messages.length) {
|
||||
self.messages[index].setContent(content);
|
||||
|
||||
self.messages.splice(index + 1);
|
||||
|
||||
self.setIsLoading(true);
|
||||
|
||||
yield new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
self.addMessage(Message.create({ content: "", role: "assistant" }));
|
||||
root.add(userMessage);
|
||||
root.setInput("");
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
messages: self.messages.slice(),
|
||||
model: self.model,
|
||||
};
|
||||
const payload = { messages: root.items.slice(), model: root.model };
|
||||
// optimistic UI delay (demo‑purpose)
|
||||
yield new Promise((r) => setTimeout(r, 500));
|
||||
root.add(Message.create({ content: "", role: "assistant" }));
|
||||
|
||||
const response = yield fetch("/api/chat", {
|
||||
const response: Response = yield fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
self.updateLastMessage(
|
||||
`Too many requests in the given time. Please wait a few moments and try again.`,
|
||||
);
|
||||
self.cleanup();
|
||||
root.updateLast("Too many requests • please slow down.");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
if (response.status > 200) {
|
||||
self.updateLastMessage(`Error! Something went wrong, try again.`);
|
||||
self.cleanup();
|
||||
root.updateLast("Error • something went wrong.");
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const { streamUrl } = yield response.json();
|
||||
const { streamUrl } = (yield response.json()) as { streamUrl: string };
|
||||
self.eventSource = new EventSource(streamUrl);
|
||||
|
||||
self.eventSource.onmessage = (event) => {
|
||||
try {
|
||||
const dataString = event.data;
|
||||
const parsedData = JSON.parse(dataString);
|
||||
|
||||
if (parsedData.type === "error") {
|
||||
self.updateLastMessage(`${parsedData.error}`);
|
||||
self.cleanup();
|
||||
self.setIsLoading(false);
|
||||
const parsed = JSON.parse(event.data);
|
||||
if (parsed.type === "error") {
|
||||
root.updateLast(parsed.error);
|
||||
cleanup();
|
||||
root.setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
parsedData.type === "chat" &&
|
||||
parsedData.data.choices[0]?.finish_reason === "stop"
|
||||
parsed.type === "chat" &&
|
||||
parsed.data.choices[0]?.finish_reason === "stop"
|
||||
) {
|
||||
self.cleanup();
|
||||
self.setIsLoading(false);
|
||||
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||
cleanup();
|
||||
root.setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsedData.type === "chat") {
|
||||
self.appendToLastMessage(
|
||||
parsedData.data.choices[0]?.delta?.content || "",
|
||||
);
|
||||
if (parsed.type === "chat") {
|
||||
root.appendLast(parsed.data.choices[0]?.delta?.content ?? "");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing stream:", error);
|
||||
} finally {
|
||||
} catch (err) {
|
||||
console.error("stream parse error", err);
|
||||
}
|
||||
};
|
||||
|
||||
self.eventSource.onerror = (e) => {
|
||||
console.log("EventSource encountered an error", JSON.stringify(e));
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error in editMessage:", error);
|
||||
self.addMessage({
|
||||
content: "Sorry, there was an error.",
|
||||
role: "assistant",
|
||||
});
|
||||
self.cleanup();
|
||||
} finally {
|
||||
self.eventSource.onerror = () => cleanup();
|
||||
} catch (err) {
|
||||
console.error("sendMessage", err);
|
||||
root.updateLast("Sorry • network error.");
|
||||
cleanup();
|
||||
root.setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}),
|
||||
getIsLoading() {
|
||||
return self.isLoading;
|
||||
},
|
||||
reset() {
|
||||
applySnapshot(self, {});
|
||||
},
|
||||
removeMessagesAfter(index: number) {
|
||||
if (index >= 0 && index < self.messages.length) {
|
||||
self.messages.splice(index + 1);
|
||||
}
|
||||
},
|
||||
updateLastMessage(content: string) {
|
||||
if (self.messages.length > 0) {
|
||||
self.messages[self.messages.length - 1].content = content;
|
||||
}
|
||||
},
|
||||
appendToLastMessage(content: string) {
|
||||
if (self.messages.length > 0) {
|
||||
self.messages[self.messages.length - 1].content += content;
|
||||
}
|
||||
},
|
||||
setIsLoading(value: boolean) {
|
||||
self.isLoading = value;
|
||||
},
|
||||
stopIncomingMessage() {
|
||||
if (self.eventSource) {
|
||||
self.eventSource.close();
|
||||
self.eventSource = null;
|
||||
}
|
||||
self.setIsLoading(false);
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
export type IMessage = Instance<typeof Message>;
|
||||
const stopIncomingMessage = () => {
|
||||
cleanup();
|
||||
root.setIsLoading(false);
|
||||
};
|
||||
|
||||
export type IClientChatStore = Instance<typeof this>;
|
||||
return { sendMessage, stopIncomingMessage, cleanup };
|
||||
});
|
||||
|
||||
export default ClientChatStore.create();
|
||||
export interface IStreamStore extends Instance<typeof StreamStore> {}
|
||||
|
||||
// ---------------------------
|
||||
// stores/ClientChatStore.ts (root)
|
||||
// ---------------------------
|
||||
import { types } from "mobx-state-tree";
|
||||
// import { MessagesStore } from "./MessagesStore";
|
||||
// import { UIStore } from "./UIStore";
|
||||
// import { ModelStore } from "./ModelStore";
|
||||
// import { StreamStore } from "./StreamStore";
|
||||
|
||||
export const ClientChatStore = types
|
||||
.compose(MessagesStore, UIStore, ModelStore, StreamStore)
|
||||
.named("ClientChatStore");
|
||||
|
||||
export const clientChatStore = ClientChatStore.create();
|
||||
|
||||
export type IClientChatStore = Instance<typeof ClientChatStore>;
|
@@ -72,5 +72,5 @@ export default ClientTransactionStore.create({
|
||||
amount: "",
|
||||
donerId: "",
|
||||
userConfirmed: false,
|
||||
transactionId: "",
|
||||
txId: "",
|
||||
});
|
||||
|
@@ -3,16 +3,21 @@ import ClientChatStore from "./ClientChatStore";
|
||||
import { runInAction } from "mobx";
|
||||
import Cookies from "js-cookie";
|
||||
|
||||
const UserOptionsStore = types
|
||||
export const UserOptionsStoreModel = types
|
||||
.model("UserOptionsStore", {
|
||||
followModeEnabled: types.optional(types.boolean, false),
|
||||
theme: types.optional(types.string, "darknight"),
|
||||
text_model: types.optional(types.string, "llama-3.3-70b-versatile"),
|
||||
})
|
||||
.actions((self) => ({
|
||||
getFollowModeEnabled: flow(function* () {
|
||||
getFollowModeEnabled() {
|
||||
return self.followModeEnabled;
|
||||
}),
|
||||
},
|
||||
resetStore() {
|
||||
self.followModeEnabled = false;
|
||||
self.theme = "darknight";
|
||||
self.text_model = "llama-3.3-70b-versatile";
|
||||
},
|
||||
storeUserOptions() {
|
||||
const userOptionsCookie = document.cookie
|
||||
.split(";")
|
||||
@@ -35,7 +40,7 @@ const UserOptionsStore = types
|
||||
Cookies.set("user_preferences", encodedUserPreferences);
|
||||
});
|
||||
},
|
||||
initialize: flow(function* () {
|
||||
initialize() {
|
||||
const userPreferencesCoookie = document.cookie
|
||||
.split(";")
|
||||
.find((row) => row.startsWith("user_preferences"));
|
||||
@@ -53,48 +58,54 @@ const UserOptionsStore = types
|
||||
self.text_model = userPreferences.text_model;
|
||||
}
|
||||
|
||||
window.addEventListener("scroll", async () => {
|
||||
window.addEventListener("scroll", () => {
|
||||
if (ClientChatStore.isLoading && self.followModeEnabled) {
|
||||
console.log("scrolling");
|
||||
await self.setFollowModeEnabled(false);
|
||||
self.setFollowModeEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("wheel", async () => {
|
||||
window.addEventListener("wheel", () => {
|
||||
if (ClientChatStore.isLoading && self.followModeEnabled) {
|
||||
console.log("wheel");
|
||||
await self.setFollowModeEnabled(false);
|
||||
self.setFollowModeEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("touchmove", async () => {
|
||||
window.addEventListener("touchmove", () => {
|
||||
console.log("touchmove");
|
||||
if (ClientChatStore.isLoading && self.followModeEnabled) {
|
||||
await self.setFollowModeEnabled(false);
|
||||
self.setFollowModeEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener("mousedown", async () => {
|
||||
window.addEventListener("mousedown", () => {
|
||||
if (ClientChatStore.isLoading && self.followModeEnabled) {
|
||||
await self.setFollowModeEnabled(false);
|
||||
self.setFollowModeEnabled(false);
|
||||
}
|
||||
});
|
||||
}),
|
||||
},
|
||||
deleteCookie() {
|
||||
document.cookie = "user_preferences=; max-age=; path=/;";
|
||||
},
|
||||
setFollowModeEnabled: flow(function* (followMode: boolean) {
|
||||
setFollowModeEnabled(followMode: boolean) {
|
||||
self.followModeEnabled = followMode;
|
||||
}),
|
||||
toggleFollowMode: flow(function* () {
|
||||
},
|
||||
toggleFollowMode() {
|
||||
self.followModeEnabled = !self.followModeEnabled;
|
||||
}),
|
||||
selectTheme: flow(function* (theme: string) {
|
||||
},
|
||||
selectTheme(theme: string) {
|
||||
self.theme = theme;
|
||||
self.storeUserOptions();
|
||||
}),
|
||||
},
|
||||
setTheme(theme: string) {
|
||||
self.theme = theme;
|
||||
},
|
||||
setTextModel(model: string) {
|
||||
self.text_model = model;
|
||||
},
|
||||
}));
|
||||
|
||||
const userOptionsStore = UserOptionsStore.create();
|
||||
const userOptionsStore = UserOptionsStoreModel.create();
|
||||
|
||||
export default userOptionsStore;
|
||||
|
43
src/stores/__tests__/AppMenuStore.test.ts
Normal file
43
src/stores/__tests__/AppMenuStore.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import menuState from '../AppMenuStore';
|
||||
|
||||
describe('AppMenuStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the menu state before each test
|
||||
menuState.closeMenu();
|
||||
});
|
||||
|
||||
it('should have isOpen set to false initially', () => {
|
||||
// Reset to initial state
|
||||
menuState.closeMenu();
|
||||
expect(menuState.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should set isOpen to true when openMenu is called', () => {
|
||||
menuState.openMenu();
|
||||
expect(menuState.isOpen).toBe(true);
|
||||
});
|
||||
|
||||
it('should set isOpen to false when closeMenu is called', () => {
|
||||
// First open the menu
|
||||
menuState.openMenu();
|
||||
expect(menuState.isOpen).toBe(true);
|
||||
|
||||
// Then close it
|
||||
menuState.closeMenu();
|
||||
expect(menuState.isOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('should toggle isOpen when toggleMenu is called', () => {
|
||||
// Initially isOpen should be false (from beforeEach)
|
||||
expect(menuState.isOpen).toBe(false);
|
||||
|
||||
// First toggle - should set to true
|
||||
menuState.toggleMenu();
|
||||
expect(menuState.isOpen).toBe(true);
|
||||
|
||||
// Second toggle - should set back to false
|
||||
menuState.toggleMenu();
|
||||
expect(menuState.isOpen).toBe(false);
|
||||
});
|
||||
});
|
190
src/stores/__tests__/ClientTransactionStore.test.ts
Normal file
190
src/stores/__tests__/ClientTransactionStore.test.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import clientTransactionStore from '../ClientTransactionStore';
|
||||
|
||||
// Mock global fetch
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
describe('ClientTransactionStore', () => {
|
||||
beforeEach(() => {
|
||||
// Reset the store to its initial state before each test
|
||||
clientTransactionStore.resetTransaction();
|
||||
clientTransactionStore.setSelectedMethod('Ethereum');
|
||||
clientTransactionStore.setAmount('');
|
||||
clientTransactionStore.setDonerId('');
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// No need to fix inconsistency anymore as we've updated the model
|
||||
});
|
||||
|
||||
describe('setSelectedMethod', () => {
|
||||
it('should set the selected method and reset userConfirmed', () => {
|
||||
// First set userConfirmed to true to verify it gets reset
|
||||
clientTransactionStore.confirmUser();
|
||||
expect(clientTransactionStore.userConfirmed).toBe(true);
|
||||
|
||||
clientTransactionStore.setSelectedMethod('Bitcoin');
|
||||
|
||||
expect(clientTransactionStore.selectedMethod).toBe('Bitcoin');
|
||||
expect(clientTransactionStore.userConfirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAmount', () => {
|
||||
it('should set the amount', () => {
|
||||
clientTransactionStore.setAmount('100');
|
||||
expect(clientTransactionStore.amount).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDonerId', () => {
|
||||
it('should set the donerId', () => {
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
expect(clientTransactionStore.donerId).toBe('donor123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('confirmUser', () => {
|
||||
it('should set userConfirmed to true', () => {
|
||||
clientTransactionStore.confirmUser();
|
||||
expect(clientTransactionStore.userConfirmed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setTransactionId', () => {
|
||||
it('should set the transaction ID', () => {
|
||||
clientTransactionStore.setTransactionId('tx123');
|
||||
expect(clientTransactionStore.txId).toBe('tx123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setDepositAddress', () => {
|
||||
it('should set the deposit address', () => {
|
||||
clientTransactionStore.setDepositAddress('0xabc123');
|
||||
expect(clientTransactionStore.depositAddress).toBe('0xabc123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resetTransaction', () => {
|
||||
it('should reset transaction-related properties', () => {
|
||||
// Set up some values first
|
||||
clientTransactionStore.setTransactionId('tx123');
|
||||
clientTransactionStore.setDepositAddress('0xabc123');
|
||||
clientTransactionStore.confirmUser();
|
||||
|
||||
// Reset the transaction
|
||||
clientTransactionStore.resetTransaction();
|
||||
|
||||
// Verify reset values
|
||||
expect(clientTransactionStore.txId).toBe('');
|
||||
expect(clientTransactionStore.depositAddress).toBe(null);
|
||||
expect(clientTransactionStore.userConfirmed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareTransaction', () => {
|
||||
it('should throw an error if amount is empty', async () => {
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
|
||||
await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data');
|
||||
});
|
||||
|
||||
it('should throw an error if donerId is empty', async () => {
|
||||
clientTransactionStore.setAmount('100');
|
||||
|
||||
await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data');
|
||||
});
|
||||
|
||||
it('should throw an error if amount is less than or equal to 0', async () => {
|
||||
clientTransactionStore.setAmount('0');
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
|
||||
await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Invalid donation data');
|
||||
});
|
||||
|
||||
it('should throw an error if the API request fails', async () => {
|
||||
// Set up valid transaction data
|
||||
clientTransactionStore.setAmount('100');
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
|
||||
// Mock a failed API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
});
|
||||
|
||||
await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow('Failed to prepare transaction');
|
||||
});
|
||||
|
||||
it('should successfully prepare an Ethereum transaction', async () => {
|
||||
// Set up valid transaction data
|
||||
clientTransactionStore.setAmount('100');
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
clientTransactionStore.setSelectedMethod('Ethereum');
|
||||
|
||||
// Mock a successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
txKey: 'tx123',
|
||||
depositAddress: 'abc123', // Without 0x prefix to test the Ethereum-specific logic
|
||||
}),
|
||||
});
|
||||
|
||||
await clientTransactionStore.prepareTransaction();
|
||||
|
||||
// Verify API call
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tx', {
|
||||
method: 'POST',
|
||||
body: 'PREPARE_TX,donor123,ethereum,100',
|
||||
});
|
||||
|
||||
// Verify store updates
|
||||
expect(clientTransactionStore.txId).toBe('tx123');
|
||||
expect(clientTransactionStore.depositAddress).toBe('0xabc123'); // Should have 0x prefix added
|
||||
expect(clientTransactionStore.userConfirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('should successfully prepare a non-Ethereum transaction', async () => {
|
||||
// Set up valid transaction data
|
||||
clientTransactionStore.setAmount('100');
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
clientTransactionStore.setSelectedMethod('Bitcoin');
|
||||
|
||||
// Mock a successful API response
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
txKey: 'tx123',
|
||||
depositAddress: 'btc123', // Bitcoin address doesn't need prefix
|
||||
}),
|
||||
});
|
||||
|
||||
await clientTransactionStore.prepareTransaction();
|
||||
|
||||
// Verify API call
|
||||
expect(mockFetch).toHaveBeenCalledWith('/api/tx', {
|
||||
method: 'POST',
|
||||
body: 'PREPARE_TX,donor123,bitcoin,100',
|
||||
});
|
||||
|
||||
// Verify store updates
|
||||
expect(clientTransactionStore.txId).toBe('tx123');
|
||||
expect(clientTransactionStore.depositAddress).toBe('btc123'); // Should not have prefix added
|
||||
expect(clientTransactionStore.userConfirmed).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle API errors and rethrow them', async () => {
|
||||
// Set up valid transaction data
|
||||
clientTransactionStore.setAmount('100');
|
||||
clientTransactionStore.setDonerId('donor123');
|
||||
|
||||
// Mock an API error
|
||||
const mockError = new Error('Network error');
|
||||
mockFetch.mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(clientTransactionStore.prepareTransaction()).rejects.toThrow(mockError);
|
||||
});
|
||||
});
|
||||
});
|
242
src/stores/__tests__/UserOptionsStore.test.ts
Normal file
242
src/stores/__tests__/UserOptionsStore.test.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import userOptionsStore, { UserOptionsStoreModel } from '../UserOptionsStore';
|
||||
import ClientChatStore from '../ClientChatStore';
|
||||
import Cookies from 'js-cookie';
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock('js-cookie', () => ({
|
||||
default: {
|
||||
set: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../ClientChatStore', () => ({
|
||||
default: {
|
||||
isLoading: false,
|
||||
setIsLoading: vi.fn((value) => {
|
||||
(ClientChatStore as any).isLoading = value;
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('UserOptionsStore', () => {
|
||||
// Mock document.cookie
|
||||
let originalDocumentCookie: PropertyDescriptor | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
// Save original document.cookie property descriptor
|
||||
originalDocumentCookie = Object.getOwnPropertyDescriptor(document, 'cookie');
|
||||
|
||||
// Mock document.cookie
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
writable: true,
|
||||
value: '',
|
||||
});
|
||||
|
||||
// Reset mocks
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Reset store to default values
|
||||
userOptionsStore.resetStore();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original document.cookie property descriptor
|
||||
if (originalDocumentCookie) {
|
||||
Object.defineProperty(document, 'cookie', originalDocumentCookie);
|
||||
}
|
||||
});
|
||||
|
||||
describe('getFollowModeEnabled', () => {
|
||||
it('should return the current followModeEnabled value', () => {
|
||||
userOptionsStore.setFollowModeEnabled(false);
|
||||
expect(userOptionsStore.getFollowModeEnabled()).toBe(false);
|
||||
|
||||
userOptionsStore.setFollowModeEnabled(true);
|
||||
expect(userOptionsStore.getFollowModeEnabled()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('storeUserOptions', () => {
|
||||
it('should store user options in a cookie', () => {
|
||||
// Set up document.cookie to simulate an existing cookie
|
||||
document.cookie = 'user_preferences=abc123';
|
||||
|
||||
userOptionsStore.setTheme('light');
|
||||
userOptionsStore.setTextModel('test-model');
|
||||
|
||||
userOptionsStore.storeUserOptions();
|
||||
|
||||
// Check that Cookies.set was called with the correct arguments
|
||||
const expectedOptions = JSON.stringify({
|
||||
theme: 'light',
|
||||
text_model: 'test-model',
|
||||
});
|
||||
const encodedOptions = btoa(expectedOptions);
|
||||
|
||||
expect(Cookies.set).toHaveBeenCalledWith('user_preferences', encodedOptions);
|
||||
});
|
||||
});
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should create a cookie if none exists', () => {
|
||||
// Ensure no cookie exists
|
||||
document.cookie = '';
|
||||
|
||||
// Mock storeUserOptions to avoid actual implementation
|
||||
const storeUserOptionsMock = vi.fn();
|
||||
const originalStoreUserOptions = userOptionsStore.storeUserOptions;
|
||||
userOptionsStore.storeUserOptions = storeUserOptionsMock;
|
||||
|
||||
try {
|
||||
userOptionsStore.initialize();
|
||||
expect(storeUserOptionsMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Restore original method
|
||||
userOptionsStore.storeUserOptions = originalStoreUserOptions;
|
||||
}
|
||||
});
|
||||
|
||||
it('should load preferences from existing cookie', () => {
|
||||
// Create a mock cookie with preferences
|
||||
const mockPreferences = {
|
||||
theme: 'light',
|
||||
text_model: 'test-model',
|
||||
};
|
||||
const encodedPreferences = btoa(JSON.stringify(mockPreferences));
|
||||
document.cookie = `user_preferences=${encodedPreferences}`;
|
||||
|
||||
userOptionsStore.initialize();
|
||||
|
||||
expect(userOptionsStore.theme).toBe('light');
|
||||
expect(userOptionsStore.text_model).toBe('test-model');
|
||||
});
|
||||
|
||||
it('should set up event listeners', () => {
|
||||
// Spy on window.addEventListener
|
||||
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
||||
|
||||
userOptionsStore.initialize();
|
||||
|
||||
// Check that event listeners were added for scroll, wheel, touchmove, and mousedown
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('scroll', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('wheel', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('touchmove', expect.any(Function));
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith('mousedown', expect.any(Function));
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCookie', () => {
|
||||
it('should delete the user_preferences cookie', () => {
|
||||
userOptionsStore.deleteCookie();
|
||||
|
||||
expect(document.cookie).toContain('user_preferences=; max-age=; path=/;');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setFollowModeEnabled', () => {
|
||||
it('should set the followModeEnabled value', () => {
|
||||
userOptionsStore.setFollowModeEnabled(true);
|
||||
expect(userOptionsStore.followModeEnabled).toBe(true);
|
||||
|
||||
userOptionsStore.setFollowModeEnabled(false);
|
||||
expect(userOptionsStore.followModeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleFollowMode', () => {
|
||||
it('should toggle the followModeEnabled value', () => {
|
||||
userOptionsStore.setFollowModeEnabled(false);
|
||||
|
||||
userOptionsStore.toggleFollowMode();
|
||||
expect(userOptionsStore.followModeEnabled).toBe(true);
|
||||
|
||||
userOptionsStore.toggleFollowMode();
|
||||
expect(userOptionsStore.followModeEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectTheme', () => {
|
||||
it('should set the theme and store user options', () => {
|
||||
// Mock storeUserOptions to avoid actual implementation
|
||||
const storeUserOptionsMock = vi.fn();
|
||||
const originalStoreUserOptions = userOptionsStore.storeUserOptions;
|
||||
userOptionsStore.storeUserOptions = storeUserOptionsMock;
|
||||
|
||||
try {
|
||||
userOptionsStore.selectTheme('light');
|
||||
expect(userOptionsStore.theme).toBe('light');
|
||||
expect(storeUserOptionsMock).toHaveBeenCalled();
|
||||
} finally {
|
||||
// Restore original method
|
||||
userOptionsStore.storeUserOptions = originalStoreUserOptions;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('event listeners', () => {
|
||||
it('should disable follow mode when scrolling if loading', () => {
|
||||
// Create a new instance of the store for this test
|
||||
const testStore = UserOptionsStoreModel.create({
|
||||
followModeEnabled: true,
|
||||
theme: "darknight",
|
||||
text_model: "llama-3.3-70b-versatile"
|
||||
});
|
||||
|
||||
// Mock ClientChatStore.isLoading
|
||||
const originalIsLoading = ClientChatStore.isLoading;
|
||||
(ClientChatStore as any).isLoading = true;
|
||||
|
||||
// Mock setFollowModeEnabled
|
||||
const setFollowModeEnabledMock = vi.fn();
|
||||
testStore.setFollowModeEnabled = setFollowModeEnabledMock;
|
||||
|
||||
// Add the event listener manually (similar to initialize)
|
||||
const scrollHandler = () => {
|
||||
if (ClientChatStore.isLoading && testStore.followModeEnabled) {
|
||||
testStore.setFollowModeEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the handler directly
|
||||
scrollHandler();
|
||||
|
||||
// Restore original value
|
||||
(ClientChatStore as any).isLoading = originalIsLoading;
|
||||
|
||||
expect(setFollowModeEnabledMock).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not disable follow mode when scrolling if not loading', () => {
|
||||
// Create a new instance of the store for this test
|
||||
const testStore = UserOptionsStoreModel.create({
|
||||
followModeEnabled: true,
|
||||
theme: "darknight",
|
||||
text_model: "llama-3.3-70b-versatile"
|
||||
});
|
||||
|
||||
// Mock ClientChatStore.isLoading
|
||||
const originalIsLoading = ClientChatStore.isLoading;
|
||||
(ClientChatStore as any).isLoading = false;
|
||||
|
||||
// Mock setFollowModeEnabled
|
||||
const setFollowModeEnabledMock = vi.fn();
|
||||
testStore.setFollowModeEnabled = setFollowModeEnabledMock;
|
||||
|
||||
// Add the event listener manually (similar to initialize)
|
||||
const scrollHandler = () => {
|
||||
if (ClientChatStore.isLoading && testStore.followModeEnabled) {
|
||||
testStore.setFollowModeEnabled(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Trigger the handler directly
|
||||
scrollHandler();
|
||||
|
||||
// Restore original value
|
||||
(ClientChatStore as any).isLoading = originalIsLoading;
|
||||
|
||||
expect(setFollowModeEnabledMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
Reference in New Issue
Block a user