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 @@
+
+
+
+
+
+
+ Login
+
+
+
+
+
+
+
\ 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);
+ },
+};