diff --git a/backend/src/permissions.js b/backend/src/permissions.js index 4f0857a..6aa61c4 100644 --- a/backend/src/permissions.js +++ b/backend/src/permissions.js @@ -24,7 +24,7 @@ export const permissions = shield( { Query: { "*": forbidden, - posts: isAuthenticated, + posts: allow, users: isAuthenticated, }, Mutation: { @@ -39,6 +39,5 @@ export const permissions = shield( }, { allowExternalErrors: true, - fallbackRule: isAuthenticated, } ); diff --git a/backend/src/posts.spec.js b/backend/src/posts.spec.js index 24fc98a..5779f8f 100644 --- a/backend/src/posts.spec.js +++ b/backend/src/posts.spec.js @@ -71,16 +71,6 @@ describe("queries", () => { let postQuery = () => query({ query: POSTS }); - it("throws error when user is not authorised", async () => { - userId = null; - await expect(postQuery()).resolves.toMatchObject({ - data: { - posts: null, - }, - errors: [expect.objectContaining({ message: "Not Authorised!" })], - }); - }); - it("returns empty array", async () => { await expect(postQuery()).resolves.toMatchObject({ errors: undefined, diff --git a/webapp/.gitignore b/webapp/.gitignore index 6bbef38..e74d5ef 100644 --- a/webapp/.gitignore +++ b/webapp/.gitignore @@ -19,4 +19,5 @@ npm-debug.log* storybook-static ### Nuxt.js### -.nuxt \ No newline at end of file +.nuxt +static/sw.js \ No newline at end of file diff --git a/webapp/README.md b/webapp/README.md index c3c21be..eb7851c 100644 --- a/webapp/README.md +++ b/webapp/README.md @@ -2,19 +2,39 @@ ## Build Setup -```bash -# install dependencies -$ npm install +### install dependencies -# serve with hot reload at localhost:3000 -$ npm run dev +``` +npm install +``` + +### serve with hot reload at localhost:3000 + +``` +npm run dev +``` + +### build for production and launch server + +``` +npm run build +npm run start +``` -# build for production and launch server -$ npm run build -$ npm run start +### generate static project -# generate static project -$ npm run generate ``` +npm run generate +``` + +### Lint files -For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). +``` +npm run lint +``` + +### Run Storybook + +``` +npm run storybook +``` diff --git a/webapp/components/LoginForm/LoginForm.spec.js b/webapp/components/LoginForm/LoginForm.spec.js new file mode 100644 index 0000000..8eff832 --- /dev/null +++ b/webapp/components/LoginForm/LoginForm.spec.js @@ -0,0 +1,75 @@ +import { createLocalVue, shallowMount } from "@vue/test-utils"; +import Vuex from "vuex"; +import LoginForm from "./LoginForm.vue"; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe("LoginForm.vue", () => { + let wrapper; + let actions; + let store; + + const createComponent = () => { + store = new Vuex.Store({ + actions, + }); + wrapper = shallowMount(LoginForm, { + store, + localVue, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders a Vue component", () => { + createComponent(); + expect(wrapper.exists()).toBe(true); + }); + + it("renders an email input field", () => { + createComponent(); + const emailInput = wrapper.find("input#email"); + expect(emailInput.exists()).toBe(true); + }); + + it("renders a password input field", () => { + createComponent(); + const passwordInput = wrapper.find("input#password"); + expect(passwordInput.exists()).toBe(true); + }); + + it("renders a submit button", () => { + createComponent(); + const submitBtn = wrapper.find("input#submit"); + expect(submitBtn.exists()).toBe(true); + }); + + describe("invalid credentials", () => { + let login; + beforeEach(() => { + login = jest.fn().mockRejectedValue(new Error("test")); + actions = { + login, + }; + createComponent(); + }); + + describe("click submit button", () => { + beforeEach(() => { + const submitBtn = wrapper.find("input#submit"); + submitBtn.trigger("click"); + }); + it("calls login", () => { + expect(login.mock.calls.length).toBe(1); + }); + it("renders error message", () => { + const invCredentialsMsg = wrapper.find("#invCredentialsMsg"); + expect(invCredentialsMsg.exists()).toBe(true); + expect(invCredentialsMsg.text()).toContain("Falsche Email oder Passwort"); + }); + }); + }); +}); diff --git a/webapp/components/LoginForm/LoginForm.vue b/webapp/components/LoginForm/LoginForm.vue new file mode 100644 index 0000000..9aa4b21 --- /dev/null +++ b/webapp/components/LoginForm/LoginForm.vue @@ -0,0 +1,64 @@ + + + + + \ No newline at end of file diff --git a/webapp/components/NavBar/NavBar.spec.js b/webapp/components/NavBar/NavBar.spec.js new file mode 100644 index 0000000..8deb0fe --- /dev/null +++ b/webapp/components/NavBar/NavBar.spec.js @@ -0,0 +1,86 @@ +import { createLocalVue, shallowMount, RouterLinkStub } from "@vue/test-utils"; +import Vuex from "vuex"; +import NavBar from "./NavBar.vue"; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe("NavBar.vue", () => { + let wrapper; + let actions; + let getters; + let store; + + const createComponent = () => { + store = new Vuex.Store({ + actions, + getters, + }); + wrapper = shallowMount(NavBar, { + store, + localVue, + stubs: { + NuxtLink: RouterLinkStub, + }, + }); + }; + beforeEach(() => { + getters = { + loggedIn: () => false, + }; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it("renders a Vue component", () => { + createComponent(); + expect(wrapper.exists()).toBe(true); + }); + + describe("not logged in", () => { + beforeEach(() => createComponent()); + + it("shows login link", () => { + const loginLink = wrapper.findComponent(RouterLinkStub); + expect(loginLink.exists()).toBe(true); + }); + it("links to /login page", () => { + const loginLink = wrapper.findComponent(RouterLinkStub); + expect(loginLink.props().to).toBe("/login"); + }); + it("does not show logout button", () => { + const logoutBtn = wrapper.find("#logoutBtn"); + expect(logoutBtn.exists()).toBe(false); + }); + }); + + describe("logged in", () => { + let logout = jest.fn(); + + beforeEach(() => { + getters = { + loggedIn: () => true, + }; + actions = { + logout, + }; + createComponent(); + }); + + it("shows logout button", () => { + const logoutBtn = wrapper.find("#logoutBtn"); + expect(logoutBtn.exists()).toBe(true); + }); + it("calls logout when logout button is clicked", () => { + const logoutBtn = wrapper.find("#logoutBtn"); + logoutBtn.trigger("click"); + expect(logout.mock.calls.length).toBe(1); + }); + it("does not show login link", () => { + const loginLink = wrapper.findComponent(RouterLinkStub); + expect(loginLink.exists()).toBe(false); + }); + }); +}); diff --git a/webapp/components/NavBar/NavBar.vue b/webapp/components/NavBar/NavBar.vue new file mode 100644 index 0000000..d4bd932 --- /dev/null +++ b/webapp/components/NavBar/NavBar.vue @@ -0,0 +1,26 @@ + + + + + \ No newline at end of file diff --git a/webapp/components/NewsItem/NewsItem.vue b/webapp/components/NewsItem/NewsItem.vue index 4c1952b..19aaaee 100644 --- a/webapp/components/NewsItem/NewsItem.vue +++ b/webapp/components/NewsItem/NewsItem.vue @@ -2,40 +2,48 @@
{{ item.title }} ({{ item.votes }})
- - - + + + +
diff --git a/webapp/components/NewsList/NewsList.spec.js b/webapp/components/NewsList/NewsList.spec.js index 15fa37a..313828b 100644 --- a/webapp/components/NewsList/NewsList.spec.js +++ b/webapp/components/NewsList/NewsList.spec.js @@ -1,75 +1,130 @@ -import { shallowMount, mount } from "@vue/test-utils"; +import { createLocalVue, mount } from "@vue/test-utils"; +import VueApollo from "vue-apollo"; +import Vuex from "vuex"; +import { createMockClient } from "mock-apollo-client"; import NewsList from "./NewsList.vue"; import NewsItem from "../NewsItem/NewsItem.vue"; +import { GET_POSTS } from "../../gql/queries.gql"; -const testItems = [ - { id: 1, title: "VueJS", votes: 1 }, - { id: 2, title: "TDD", votes: 4 }, - { id: 3, title: "React", votes: 0 }, -]; -const descendingTestItems = [ - { id: 2, title: "TDD", votes: 4 }, - { id: 1, title: "VueJS", votes: 1 }, - { id: 3, title: "React", votes: 0 }, -]; -const ascendingTestItems = [ - { id: 3, title: "React", votes: 0 }, - { id: 1, title: "VueJS", votes: 1 }, - { id: 2, title: "TDD", votes: 4 }, -]; +const postListMock = { + data: { + posts: [ + { + id: "1", + title: "Vue", + votes: 4, + author: { + id: "1", + __typename: "User", + }, + __typename: "Post", + }, + { + id: "2", + title: "React", + votes: 0, + author: { + id: "2", + __typename: "User", + }, + __typename: "Post", + }, + { + id: "3", + title: "TDD", + votes: 2, + author: { + __typename: "User", + id: "3", + }, + __typename: "Post", + }, + ], + }, +}; + +const localVue = createLocalVue(); +localVue.use(VueApollo); +localVue.use(Vuex); describe("NewsList.vue", () => { - describe("empty", () => { - it("renders a message when the item list is empty", () => { - const wrapper = shallowMount(NewsList, { - propsData: { - initialItems: [], + let wrapper; + let mockClient; + let apolloProvider; + let requestHandlers; + let store = new Vuex.Store({ + getters: { + loggedIn: () => false, + }, + mutations: { + setToken() {}, + }, + }); + + const createComponent = (handlers) => { + mockClient = createMockClient({ + resolvers: {}, + }); + requestHandlers = { + allPostsQueryHandler: jest.fn().mockResolvedValue({ ...postListMock }), + ...handlers, + }; + mockClient.setRequestHandler(GET_POSTS, requestHandlers.allPostsQueryHandler); + apolloProvider = new VueApollo({ defaultClient: mockClient }); + const getToken = jest.fn(); + wrapper = mount(NewsList, { + store, + localVue, + apolloProvider, + mocks: { + $apolloHelpers: { + getToken, }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + mockClient = null; + apolloProvider = null; + }); + + it("renders a Vue component", () => { + createComponent(); + expect(wrapper.exists()).toBe(true); + expect(wrapper.vm.$apollo.queries.items).toBeTruthy(); + }); + + describe("empty", () => { + it("renders a message when the item list is empty", async () => { + createComponent({ + allPostsQueryHandler: jest.fn().mockResolvedValue({ data: { posts: [] } }), }); + await localVue.nextTick(); + expect(wrapper.find("#emptyListMessage").text()).toBe("The list is empty :("); }); }); describe("not empty", () => { - let wrapper; - beforeEach(() => { - wrapper = mount(NewsList, { - propsData: { - initialItems: testItems, - }, - }); + beforeEach(async () => { + createComponent(); + await localVue.nextTick(); }); it("does not render empty list message when item list is filled", () => { expect(wrapper.find("#emptyListMessage").exists()).toBe(false); }); - it("orderedItems sorts items in descending order by default", () => { - let localThis = { - items: testItems, - descending: true, - }; - expect(NewsList.computed.orderedItems.call(localThis)).toEqual(descendingTestItems); - }); it("renders items in descending order by default", () => { let newsItems = wrapper.findAllComponents(NewsItem); - expect(newsItems.wrappers.map((i) => i.props("item").title)).toEqual( - descendingTestItems.map((i) => i.title) - ); + expect(newsItems.wrappers.map((i) => i.props("item").title)).toEqual(["Vue", "TDD", "React"]); }); describe("Reverse order", () => { - it("orderedItems sorts items in ascending order", () => { - let localThis = { - items: testItems, - descending: false, - }; - expect(NewsList.computed.orderedItems.call(localThis)).toEqual(ascendingTestItems); - }); describe("click 'Reverse Order'", () => { it("renders items in ascending order", async () => { let reverseOrderButton = wrapper.find("#reverseOrder"); await reverseOrderButton.trigger("click"); let newsItems = wrapper.findAllComponents(NewsItem); - expect(newsItems.wrappers.map((i) => i.props("item").title)).toEqual( - ascendingTestItems.map((i) => i.title) - ); + expect(newsItems.wrappers.map((i) => i.props("item").title)).toEqual(["React", "TDD", "Vue"]); }); }); }); diff --git a/webapp/components/NewsList/NewsList.vue b/webapp/components/NewsList/NewsList.vue index b436fd4..46cb98d 100644 --- a/webapp/components/NewsList/NewsList.vue +++ b/webapp/components/NewsList/NewsList.vue @@ -5,8 +5,9 @@
@@ -15,65 +16,99 @@ - + \ No newline at end of file diff --git a/webapp/static/sw.js b/webapp/static/sw.js deleted file mode 100644 index 0f5ce38..0000000 --- a/webapp/static/sw.js +++ /dev/null @@ -1,18 +0,0 @@ -// THIS FILE SHOULD NOT BE VERSION CONTROLLED - -// https://github.com/NekR/self-destroying-sw - -self.addEventListener("install", function() { - self.skipWaiting(); -}); - -self.addEventListener("activate", function() { - self.registration - .unregister() - .then(function() { - return self.clients.matchAll(); - }) - .then(function(clients) { - clients.forEach((client) => client.navigate(client.url)); - }); -}); diff --git a/webapp/store/index.js b/webapp/store/index.js new file mode 100644 index 0000000..bce577c --- /dev/null +++ b/webapp/store/index.js @@ -0,0 +1,37 @@ +import login from "../gql/login.gql"; +import jwt_decode from "jwt-decode"; + +export const state = () => ({ + token: null, + currentUser: null, +}); + +export const getters = { + loggedIn(state) { + return !!state.token && !!state.currentUser; + }, +}; + +export const mutations = { + setToken(state, token) { + state.token = token; + if (token) { + let { userId } = jwt_decode(token); + state.currentUser = userId; + } else { + state.currentUser = null; + } + }, +}; + +export const actions = { + async login({ commit }, { email, password, apolloClient }) { + const { data } = await apolloClient.mutate({ mutation: login, variables: { email, password } }); + await this.$apolloHelpers.onLogin(data.login); + commit("setToken", data.login); + }, + logout({ commit }) { + this.$apolloHelpers.onLogout(); + commit("setToken", null); + }, +};