I'm trying to follow Ben Awad fullstack reddit clone tutorial.
using express-session I'm trying to send the client a cookie and userID each time they login - but when I do so the cookie is being set but the server stops responding from that client (either apollo studio or postman). If the cookie is deleted the server returns to function normally.
I have compared my code to others and could not find anything off.
what can I be missing ?
import { MikroORM } from "#mikro-orm/core";
import microConfig from "./mikro-orm.config";
import express from "express";
import { ApolloServer } from "apollo-server-express";
import { buildSchema } from "type-graphql";
import { HelloResolver } from "./resolvers/hello";
import { PostResolver } from "./resolvers/Post";
import { UserResolver } from "./resolvers/User";
import {__prod__} from "./constants";
import {createClient} from "redis";
import session from "express-session";
import connectRedis from "connect-redis";
import "reflect-metadata";
const main = async () => {
const orm = await MikroORM.init(microConfig);
await orm.getMigrator().up();
const app = express();
const RedisStore = connectRedis(session);
const redisClient = createClient();
await redisClient.connect();
const appSession = session({
name: "cid",
secret: "shhhh",
resave: false,
saveUninitialized: false,
store: new RedisStore({
client: redisClient,
})
,
cookie: {
maxAge: 1000 * 60 * 60 * 24,
secure: true,
httpOnly: false,
sameSite: "none",
},
})
app.use(appSession);
!__prod__ && app.set("trust proxy", 1);
app.set("Access-Control-Allow-Origin", "https://studio.apollographql.com");
app.set("Access-Control-Allow-Credentials", true)
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [HelloResolver, PostResolver, UserResolver],
validate: false,
}),
context: ({ res, req }) => ({ em: orm.em, res, req }),
introspection: !__prod__,
});
await apolloServer.start();
apolloServer.applyMiddleware({
app,
cors: {
origin: ["https://studio.apollographql.com"],
credentials: true,
},
});
app.get("/hello", (_, res) => {
res.send("Hello World");
});
app.listen(4000, () => {
console.log("server started on localhost:4000");
});
};
main().catch((err) => {
console.error(err);
})
Problem was that legacyMode needs to be set to true in createClient for redis to work properly with connect-redis :)
Related
Basically this other post Express-session does not set cookie? where I'm following Ben Awad's Fullstack Tutorial. The cookie gets created but the server crashes and this is the error
node:internal/errors:464
ErrorCaptureStackTrace(err);
^
TypeError [ERR_INVALID_ARG_TYPE]: The "chunk" argument must be of type string or an instance of Buffer or Uint8Array. Received an instance of Array
at new NodeError (node:internal/errors:371:5)
at _write (node:internal/streams/writable:312:13)
at Socket.Writable.write (node:internal/streams/writable:334:10)
at RedisSocket.writeCommand (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/#node-redis/client/dist/lib/client/socket.js:57:130)
at Commander._RedisClient_tick (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/#node-redis/client/dist/lib/client/index.js:415:64)
at Commander._RedisClient_sendCommand (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/#node-redis/client/dist/lib/client/index.js:396:82)
at Commander.commandsExecutor (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/#node-redis/client/dist/lib/client/index.js:160:154)
at Commander.BaseClass.<computed> [as set] (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/#node-redis/client/dist/lib/commander.js:8:29)
at RedisStore.set (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/connect-redis/lib/connect-redis.js:65:21)
at Session.save (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/express-session/session/session.js:72:25)
at Session.save (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/express-session/index.js:406:15)
at ServerResponse.end (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/express-session/index.js:335:21)
at ServerResponse.send (/home/kuratar/github/milestone-4-Kuratar/server/node_modules/express/lib/response.js:221:10)
at /home/kuratar/github/milestone-4-Kuratar/server/node_modules/apollo-server-express/dist/ApolloServer.js:89:25 {
code: 'ERR_INVALID_ARG_TYPE'
}
I noticed that this specific line of code in user.ts:
req.session.userId = user.id
when it's commented out, the error doesn't occur but the cookie is not set. There isn't a set-cookie option in the response-header.
My files are pretty much the same as this other person in the post I linked.
index.ts
import "reflect-metadata";
import { MikroORM } from "#mikro-orm/core";
import { __prod__ } from "./constants";
import microConfig from "./mikro-orm.config";
import express from "express";
import { ApolloServer } from "apollo-server-express";
import { ApolloServerPluginLandingPageGraphQLPlayground } from "apollo-server-core";
import { buildSchema } from "type-graphql";
import { HelloResolver } from "./resolvers/hello";
import { PostResolver } from "./resolvers/post";
import { UserResolver } from "./resolvers/user";
import * as redis from "redis";
import session from "express-session";
import connectRedis from "connect-redis";
import { MyContext } from "./types";
// start postgresql server on wsl - sudo service postgresql start
// stop - sudo service postgresql stop
// start redis server on wsl - redis-server
// sudo /etc/init.d/redis-server restart
// stop, start
// watch ts changes - npm run watch
// run server - npm run dev
const main = async () => {
const orm = await MikroORM.init(microConfig); // initialize database
await orm.getMigrator().up(); // run migrations before anything else
const app = express();
app.set("trust proxy", 1); // trust first proxy
// this comes before applyMiddleware since use session middleware inside apollo
const RedisStore = connectRedis(session);
const redisClient = redis.createClient(); // TODO: TypeError: Cannot read properties of undefined (reading 'createClient')
redisClient.on("error", (err) => console.log("Redis Client Error", err));
await redisClient.connect();
app.use(
session({
name: "qid",
// touch - make request to redis to reset the user's session
// if user does something, it means they are active and should reset the timer of automatically logging them out
// after 24 hours for example
// disableTouch: true - keep session forever, can change this later to timed sessions
store: new RedisStore({ client: redisClient, disableTouch: true }), // tell express session using redis
cookie: {
maxAge: 1000 * 60 * 60 * 24 * 365 * 10, // 10 years
httpOnly: true,
sameSite: "lax", // csrf
secure: __prod__, // only works in https
},
saveUninitialized: false,
secret: "askljdhfjkalshdjlf", // want to keep this secret separately
resave: true,
rolling: true,
})
);
// app.use(function (req, res, next) {
// res.header(
// "Access-Control-Allow-Origin",
// "https://studio.apollographql.com"
// );
// res.header("Access-Control-Allow-Credentials", "true");
// next();
// });
const apolloServer = new ApolloServer({
schema: await buildSchema({
resolvers: [HelloResolver, PostResolver, UserResolver],
validate: false,
}),
// object that is accessible by resolvers, basically pass the database itself
context: ({ req, res }): MyContext => ({ em: orm.em, req, res }),
plugins: [
ApolloServerPluginLandingPageGraphQLPlayground({
settings: { "request.credentials": "include" },
}),
],
});
await apolloServer.start();
const corsOptions = {
origin: new RegExp("/*/"),
credentials: true,
};
apolloServer.applyMiddleware({ app, cors: corsOptions }); // create graphql endpoint on express
app.listen(4000, () => {
console.log("Server started on localhost:4000");
});
};
main().catch((error) => {
console.log("----------MAIN CATCHED ERROR----------");
console.error(error);
console.log("-----------------END------------------");
});
user.ts
import {
Resolver,
Arg,
Mutation,
InputType,
Field,
Ctx,
ObjectType,
} from "type-graphql";
import { User } from "../entities/User";
import { MyContext } from "../types";
import argon2 from "argon2";
// another way to implementing arguments for methods instead of #Arg()
#InputType()
class UsernamePasswordInput {
#Field()
username: string;
#Field()
password: string;
}
#ObjectType()
class FieldError {
#Field()
field: string;
#Field()
message: string;
}
#ObjectType()
class UserResponse {
#Field(() => [FieldError], { nullable: true })
errors?: FieldError[];
#Field(() => User, { nullable: true })
user?: User;
}
#Resolver()
export class UserResolver {
#Mutation(() => UserResponse)
async register(
#Arg("options") options: UsernamePasswordInput,
#Ctx() { em }: MyContext
): Promise<UserResponse> {
if (options.username.length <= 2) {
return {
errors: [
{ field: "username", message: "length must be greater than 2" },
],
};
}
if (options.password.length <= 2) {
return {
errors: [
{ field: "password", message: "length must be greater than 2" },
],
};
}
// argon2 is a password hasher package
const hashedPassword = await argon2.hash(options.password);
const user = em.create(User, {
username: options.username,
password: hashedPassword,
});
try {
await em.persistAndFlush(user);
} catch (error) {
// duplicate username error
if (error.code === "23505") {
// || error.detail.includes("already exists")
return {
errors: [{ field: "username", message: "Username already taken" }],
};
}
}
// return user in an object since response is now a response object - UserResponse
return { user };
}
#Mutation(() => UserResponse)
async login(
#Arg("options") options: UsernamePasswordInput,
#Ctx() { em, req }: MyContext
): Promise<UserResponse> {
// argon2 is a password hasher package
const user = await em.findOne(User, {
username: options.username,
});
// can give same field error message like invalid login
if (!user) {
return {
errors: [{ field: "username", message: "That username doesn't exist" }],
};
}
const valid = await argon2.verify(user.password, options.password);
if (!valid) {
return {
errors: [{ field: "password", message: "Incorrect password" }],
};
}
// mutation {
// login(options: {username: "eric", password: "eric"}) {
// errors {
// field
// message
// }
// user {
// id
// username
// }
// }
// }
console.log(req.session)
console.log(user.id)
req.session.userId = user.id
console.log(req.session)
console.log(req.session.id)
// console.log(req.session.userId)
// return user in an object since response is now a response object - UserResponse
return { user };
}
}
types.ts
import { EntityManager, IDatabaseDriver, Connection } from "#mikro-orm/core";
import { Request, Response } from "express";
import { Session, SessionData } from "express-session";
// this is the type of orm.em from index.ts
// extracted to make code look cleaner in post.ts
export type MyContext = {
em: EntityManager<any> & EntityManager<IDatabaseDriver<Connection>>;
req: Request & {
session: Session & Partial<SessionData> & { userId?: number };
};
res: Response;
};
I've had the same error. In my situation I was able to fix it by changing the redis client to ioredis(I was using redis).
To be more specific on Bernardo, Ben also changes it to ioredis in the github repo. So you need to install ioredis and add these lines
import Redis from "ioredis";
const redis = new Redis(process.env.REDIS_URL);
and delete/comment out the old redisClient lines of code.
I want to create a Mock API Server for my Jest tests so that I can define all my backend endpoints and create responses and authentication checks.
I have managed to set up the server and routes by following some of the source code from Chris Fritz "Vue-Enterprice-boilerplate":
https://github.com/chrisvfritz/vue-enterprise-boilerplate/tree/master/tests/unit
// jest.config.js
const _ = require("lodash");
process.env.MOCK_API_PORT = process.env.MOCK_API_PORT || _.random(9000, 9999);
module.exports = {
preset: "#vue/cli-plugin-unit-jest",
setupFiles: ["./tests/unit/setup"],
globalSetup: "<rootDir>/tests/unit/global-setup",
globalTeardown: "<rootDir>/tests/unit/global-teardown",
testMatch: ["**/(*.)spec.js"],
moduleFileExtensions: ["js", "jsx", "json", "vue"],
transform: {
"^.+\\.vue$": "vue-jest",
"^.+\\.js$": "babel-jest",
".+\\.(css|scss|jpe?g|png|gif|webp|svg|mp4|webm|ogg|mp3|wav|flac|aac|woff2?|eot|ttf|otf)$":
"jest-transform-stub"
},
transformIgnorePatterns: ["/node_modules/(?!vue-spinner)"],
testURL: process.env.API_BASE_URL || `http://localhost:${process.env.MOCK_API_PORT}`
};
The server runs when the tests starts and I can console log the route files.
I just don't know how the axios call from my Vuex would go with the mock API instead of the real one.
Might need to import axios somewhere in the test to prevent the development URL to be used?
/tests/mock-api/routes/auth.js
const Users = require("../resources/users");
module.exports = app => {
console.log('I can see this during tests!');
app.post("/api/v1/login", async (req, res) => {
console.log("I don't see this..");
await Users.authenticate(req.body)
.then(user => {
res.json(user);
})
.catch(error => {
res.status(401).json({ message: error.message });
});
});
});
// /views/Login.spec.js
import Vue from "vue";
import Vuelidate from "vuelidate";
import Login from "#/views/Login";
import BaseButton from "#/components/Globals/_base-button.vue";
import BaseInput from "#/components/Globals/_base-input.vue";
import BaseLabel from "#/components/Globals/_base-label.vue";
import flushPromises from "flush-promises";
import store from "#/store";
import { shallowMount } from "#vue/test-utils";
Vue.use(Vuelidate);
describe("#/views/Login", () => {
// other tests..
it("redirects to posts on successful login", async () => {
const wrapper = shallowMount(Login, { store, stubs: { BaseInput, BaseButton, BaseLabel } });
wrapper.vm.$v.$touch();
const spyDispatch = jest.spyOn(wrapper.vm.$store, "dispatch");
const username = wrapper.find("#username");
const password = wrapper.find("#password");
username.element.value = "johndoe#email.com";
password.element.value = "passwordz";
username.trigger("input");
password.trigger("input");
await wrapper.find("#submitBtn").trigger("click.prevent");
await wrapper.vm.$nextTick();
await flushPromises();
await expect(spyDispatch).toHaveBeenCalledWith("auth/login", {
username: username.element.value,
password: password.element.value
});
// #TODO add expect for redirect as well
});
// /store/auth.js (vuex)
export const actions = {
async login({ commit }, { username, password }) {
console.log("I see this");
const response = await axios.post("/login",
{ username, password }, { withCredentials: true });
console.log("I don't see this");
// #TODO error handling
if (!response) return;
commit("setUser", { ...response.data.user });
router.push({ name: "Posts" });
},
The login action gets called but I don't get passed the axios.post.
Do I need to import axios somewhere to make sure I get a fresh instance? (Vuex uses one I set the baseURL and headers)
All the other tests and logic works except this.
I have the following set up in my nuxt.config.js file:
auth: {
redirect: {
login: '/accounts/login',
logout: '/',
callback: '/accounts/login',
home: '/'
},
strategies: {
local: {
endpoints: {
login: { url: 'http://localhost:8000/api/login2/', method: 'post' },
user: {url: 'http://localhost:8000/api/user/', method: 'get', propertyName: 'user' },
tokenRequired: false,
tokenType: false
}
}
},
localStorage: false,
cookie: true
},
I am using django sessions for my authentication backend, which means that upon a successful login, i will have received a session-id in my response cookie. When i authenticate with nuxt however, i see the cookie in the response, but the cookie is not saved to be used in further requests. Any idea what else i need to be doing?
This is how I handled this, which came from a forum post that I cannot find since. First get rid of nuxt/auth and roll your own with vuex store. You will want two middleware, one to apply to pages you want auth on, and another for the opposite.
This assumes you have a profile route and a login route that returns a user json on successful login.
I'm also writing the user to a cookie called authUser, but that was just for debugging and can be removed if you don't need it.
store/index
import state from "./state";
import * as actions from "./actions";
import * as mutations from "./mutations";
import * as getters from "./getters";
export default {
state,
getters,
mutations,
actions,
modules: {},
};
store/state
export default () => ({
user: null,
isAuthenticated: false,
});
store/actions
export async function nuxtServerInit({ commit }, { _req, res }) {
await this.$axios
.$get("/api/users/profile")
.then((response) => {
commit("setUser", response);
commit("setAuthenticated", true);
})
.catch((error) => {
commit("setErrors", [error]); // not covered in this demo
commit("setUser", null);
commit("setAuthenticated", false);
res.setHeader("Set-Cookie", [
`session=false; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
`authUser=false; expires=Thu, 01 Jan 1970 00:00:00 GMT`,
]);
});
}
store/mutations
export const setUser = (state, payload) => (state.user = payload);
export const setAuthenticated = (state, payload) =>
(state.isAuthenticated = payload);
store/getters
export const getUser = (state) => state.user;
export const isAuthenticated = (state) => state.isAuthenticated;
middleware/redirectIfNoUser
export default function ({ app, redirect, _route, _req }) {
if (!app.store.state.user || !app.store.state.isAuthenticated) {
return redirect("/auth/login");
}
}
middleware/redirectIfUser
export default function ({ app, redirect, _req }) {
if (app.store.state.user) {
if (app.store.state.user.roles.includes("customer")) {
return redirect({
name: "panel",
params: { username: app.store.state.user.username },
});
} else if (app.store.state.user.roles.includes("admin")) {
return redirect("/admin/dashboard");
} else {
return redirect({
name: "panel",
});
}
} else {
return redirect("/");
}
}
pages/login- login method
async userLogin() {
if (this.form.username !== "" && this.form.password !== "") {
await this.$axios
.post("/api/auth/login", this.form)
.then((response) => {
this.$store.commit("setUser", response.data);
this.$store.commit("setAuthenticated", true);
this.$cookies.set("authUser", JSON.stringify(response.data), {
maxAge: 60 * 60 * 24 * 7,
});
if (this.$route.query.redirect) {
this.$router.push(this.$route.query.redirect);
}
this.$router.push("/panel");
})
.catch((e) => {
this.$toast
.error("Error logging in", { icon: "error" })
.goAway(800);
The cookie is sent by the server but the client won't read it, until you set the property withCredentials in your client request (about withCredentials read here)
To fix your problem you have to extend your auth config with withCredentials property.
endpoints: {
login: {
url: 'http://localhost:8000/api/login2/',
method: 'post'
withCredentials: true
}
}
Also don't forget to set CORS policies on your server as well to support cookie exchange
Example from ExpressJS
app.use(cors({ credentials: true, origin: "http://localhost:8000" }))
More information about this issue on auth-module github
Apollo link offers an error handler onError
Issue:
Currently, we wish to refresh oauth tokens when they expires during an apollo call and we are unable to execute an async fetch request inside the onError properly.
Code:
initApolloClient.js
import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, fromPromise } from 'apollo-link';
//Define Http link
const httpLink = new createHttpLink({
uri: '/my-graphql-endpoint',
credentials: 'include'
});
//Add on error handler for apollo link
return new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
//User access token has expired
if(graphQLErrors[0].message==="Unauthorized") {
//We assume we have both tokens needed to run the async request
if(refreshToken && clientToken) {
//let's refresh token through async request
return fromPromise(
authAPI.requestRefreshToken(refreshToken,clientToken)
.then((refreshResponse) => {
let headers = {
//readd old headers
...operation.getContext().headers,
//switch out old access token for new one
authorization: `Bearer ${refreshResponse.access_token}`,
};
operation.setContext({
headers
});
//Retry last failed request
return forward(operation);
})
.catch(function (error) {
//No refresh or client token available, we force user to login
return error;
})
)
}
}
}
}
}
}),
What happens is:
Initial graphQL query runs and fails due to unauthorization
The onError function of ApolloLink is executed.
The promise to refresh the token is executed.
The onError function of ApolloLink is executed again??
The promise to refresh the token is completed.
The initial graphQL query result is returned and its data is undefined
Between step 5 and 6, apollo doesn't re-run the initial failed graphQL query and hence the result is undefined.
Errors from console:
Uncaught (in promise) Error: Network error: Error writing result to store for query:
query UserProfile($id: ID!) {
UserProfile(id: $id) {
id
email
first_name
last_name
}
__typename
}
}
The solution should allow us to:
Run an async request when an operation fails
Wait for the result of the request
Retry failed operation with data from the request's result
Operation should succeed to return its intended result
I'm refreshing the token this way (updated OP's):
import { ApolloClient } from 'apollo-client';
import { onError } from 'apollo-link-error';
import { ApolloLink, Observable } from 'apollo-link'; // add Observable
// Define Http link
const httpLink = new createHttpLink({
uri: '/my-graphql-endpoint',
credentials: 'include'
});
// Add on error handler for apollo link
return new ApolloClient({
link: ApolloLink.from([
onError(({ graphQLErrors, networkError, operation, forward }) => {
// User access token has expired
if (graphQLErrors && graphQLErrors[0].message === 'Unauthorized') {
// We assume we have both tokens needed to run the async request
if (refreshToken && clientToken) {
// Let's refresh token through async request
return new Observable(observer => {
authAPI.requestRefreshToken(refreshToken, clientToken)
.then(refreshResponse => {
operation.setContext(({ headers = {} }) => ({
headers: {
// Re-add old headers
...headers,
// Switch out old access token for new one
authorization: `Bearer ${refreshResponse.access_token}` || null,
}
}));
})
.then(() => {
const subscriber = {
next: observer.next.bind(observer),
error: observer.error.bind(observer),
complete: observer.complete.bind(observer)
};
// Retry last failed request
forward(operation).subscribe(subscriber);
})
.catch(error => {
// No refresh or client token available, we force user to login
observer.error(error);
});
});
}
}
})
])
});
Accepted answer is quite good but it wouldn't work with 2 or more concurrent requests. I've crafted the one below after testing different cases with my token renew workflow that fits my needs.
It's necessary to set errorLink before authLink in link pipeline.
client.ts
import { ApolloClient, from, HttpLink } from '#apollo/client'
import errorLink from './errorLink'
import authLink from './authLink'
import cache from './cache'
const httpLink = new HttpLink({
uri: process.env.REACT_APP_API_URL,
})
const apiClient = new ApolloClient({
link: from([errorLink, authLink, httpLink]),
cache,
credentials: 'include',
})
export default apiClient
Cache shared between 2 apollo client instances for setting user query when my renewal token is expired
cache.ts
import { InMemoryCache } from '#apollo/client'
const cache = new InMemoryCache()
export default cache
authLink.ts
import { ApolloLink } from '#apollo/client'
type Headers = {
authorization?: string
}
const authLink = new ApolloLink((operation, forward) => {
const accessToken = localStorage.getItem('accessToken')
operation.setContext(({ headers }: { headers: Headers }) => ({
headers: {
...headers,
authorization: accessToken,
},
}))
return forward(operation)
})
export default authLink
errorLink.ts
import { ApolloClient, createHttpLink, fromPromise } from '#apollo/client'
import { onError } from '#apollo/client/link/error'
import { GET_CURRENT_USER } from 'queries'
import { RENEW_TOKEN } from 'mutations'
import cache from './cache'
let isRefreshing = false
let pendingRequests: Function[] = []
const setIsRefreshing = (value: boolean) => {
isRefreshing = value
}
const addPendingRequest = (pendingRequest: Function) => {
pendingRequests.push(pendingRequest)
}
const renewTokenApiClient = new ApolloClient({
link: createHttpLink({ uri: process.env.REACT_APP_API_URL }),
cache,
credentials: 'include',
})
const resolvePendingRequests = () => {
pendingRequests.map((callback) => callback())
pendingRequests = []
}
const getNewToken = async () => {
const oldRenewalToken = localStorage.getItem('renewalToken')
const {
data: {
renewToken: {
session: { renewalToken, accessToken },
},
},
} = await renewTokenApiClient.mutate({
mutation: RENEW_TOKEN,
variables: { input: { renewalToken: oldRenewalToken } },
})!
localStorage.setItem('renewalToken', renewalToken)
localStorage.setItem('accessToken', accessToken)
}
const errorLink = onError(({ graphQLErrors, operation, forward }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
switch (err?.message) {
case 'expired':
if (!isRefreshing) {
setIsRefreshing(true)
return fromPromise(
getNewToken().catch(() => {
resolvePendingRequests()
setIsRefreshing(false)
localStorage.clear()
// Cache shared with main client instance
renewTokenApiClient!.writeQuery({
query: GET_CURRENT_USER,
data: { currentUser: null },
})
return forward(operation)
}),
).flatMap(() => {
resolvePendingRequests()
setIsRefreshing(false)
return forward(operation)
})
} else {
return fromPromise(
new Promise((resolve) => {
addPendingRequest(() => resolve())
}),
).flatMap(() => {
return forward(operation)
})
}
}
}
}
})
export default errorLink
We just had the same issues and after a very complicated solution with lots of Observeables we got a simple solution using promises which will be wrapped as an Observable in the end.
let tokenRefreshPromise: Promise = Promise.resolve()
let isRefreshing: boolean
function createErrorLink (store): ApolloLink {
return onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
// this is a helper method where we are checking the error message
if (isExpiredLogin(graphQLErrors) && !isRefreshing) {
isRefreshing = true
tokenRefreshPromise = store.dispatch('authentication/refreshToken')
tokenRefreshPromise.then(() => isRefreshing = false)
}
return fromPromise(tokenRefreshPromise).flatMap(() => forward(operation))
}
if (networkError) {
handleNetworkError(displayErrorMessage)
}
})
}
All pending requests are waiting for the tokenRefreshPromise and will then be forwarded.
I created an axios instance ...
// api/index.js
const api = axios.create({
baseURL: '/api/',
timeout: 2500,
headers: { Accept: 'application/json' },
});
export default api;
And severals modules use it ..
// api/versions.js
import api from './api';
export function getVersions() {
return api.get('/versions');
}
I try to test like ..
// Test
import { getVersions } from './api/versions';
const versions= [{ id: 1, desc: 'v1' }, { id: 2, desc: 'v2' }];
mockAdapter.onGet('/versions').reply(200, versions);
getVersions.then((resp) => { // resp is UNDEFINED?
expect(resp.data).toEqual(versions);
done();
});
Why resp is undefined?
Two things to try here:
Maybe you already have this elsewhere in your code, but be sure to set up mockAdaptor:
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
const mockAdapter = new MockAdapter(axios);
I haven't found a way to get the mock adapter working when the function you are testing uses 'axios.create' to set up a new axios instance. Try something along the lines of this instead:
// api/index.js
const api = {
get(path) {
return axios.get('/api' + path)
.then((response) => {
return response.data;
});
}
}
export default api;
For anyone still struggling with this.
You need to make sure you iniatilise your MockAdapter outside a test body.
ie.
❌ Incorrect ❌
it('should do a thing', () => {
const mockAdapter = new MockAdapter(axios);
})
✅ Correct ✅
const mockAdapter = new MockAdapter(axios);
it('should pass' () => {})
according to James M. advice, I updated my api/index.js , not using the axios.create...
api/index.js
import http from 'axios'
export default {
fetchShoppingLists: () => {
console.log('API FETCH SHOPPINGLISTS')
return http
.get('http://localhost:3000/shoppinglists')
.then(response => {
return response
})
.catch(error => {
console.log('FETCH ERROR: ', error)
})
}
}
You don't need axios-mock-adapter. Here is how I mock my axios:
// src/__mocks__/axios.ts
const mockAxios = jest.genMockFromModule('axios')
// this is the key to fix the axios.create() undefined error!
mockAxios.create = jest.fn(() => mockAxios)
export default mockAxios