Squashed commit of the following:

commit f6174179405cbf696415b17bbfcb157b6c3415cf
Author: sonny <sonnyba871@gmail.com>
Date:   Thu Jan 2 23:26:49 2020 +0100

    redux tests
This commit is contained in:
Sonny 2020-01-30 20:29:32 +01:00
parent 62e763604e
commit c3b087e004
20 changed files with 4733 additions and 56 deletions

View file

@ -37,6 +37,18 @@ python tests:
script: script:
- python src/manage.py test newsreader - python src/manage.py test newsreader
javascript tests:
image: node:12
stage: test
cache:
key: "$CI_JOB_NAME-$CI_COMMIT_REF_SLUG"
paths:
- node_modules/
before_script:
- npm install
script:
- npm test
javascript linting: javascript linting:
image: node:12 image: node:12
stage: lint stage: lint

188
jest.config.js Normal file
View file

@ -0,0 +1,188 @@
// For a detailed explanation regarding each configuration property, visit:
// https://jestjs.io/docs/en/configuration.html
module.exports = {
// All imported modules in your tests should be mocked automatically
// automock: false,
// Stop running tests after `n` failures
// bail: 0,
// Respect "browser" field in package.json when resolving modules
// browser: false,
// The directory where Jest should store its cached dependency information
// cacheDirectory: "/tmp/jest_rs",
// Automatically clear mock calls and instances between every test
clearMocks: true,
// Indicates whether the coverage information should be collected while executing the test
// collectCoverage: false,
// An array of glob patterns indicating a set of files for which coverage information should be collected
// collectCoverageFrom: null,
// The directory where Jest should output its coverage files
coverageDirectory: 'coverage',
// An array of regexp pattern strings used to skip coverage collection
// coveragePathIgnorePatterns: [
// "/node_modules/"
// ],
// A list of reporter names that Jest uses when writing coverage reports
// coverageReporters: [
// "json",
// "text",
// "lcov",
// "clover"
// ],
// An object that configures minimum threshold enforcement for coverage results
// coverageThreshold: null,
// A path to a custom dependency extractor
// dependencyExtractor: null,
// Make calling deprecated APIs throw helpful error messages
// errorOnDeprecated: false,
// Force coverage collection from ignored files using an array of glob patterns
// forceCoverageMatch: [],
// A path to a module which exports an async function that is triggered once before all test suites
// globalSetup: null,
// A path to a module which exports an async function that is triggered once after all test suites
// globalTeardown: null,
// A set of global variables that need to be available in all test environments
// globals: {},
// The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.
// maxWorkers: "50%",
// An array of directory names to be searched recursively up from the requiring module's location
// moduleDirectories: [
// "node_modules"
// ],
// An array of file extensions your modules use
// moduleFileExtensions: [
// "js",
// "json",
// "jsx",
// "ts",
// "tsx",
// "node"
// ],
// A map from regular expressions to module names that allow to stub out resources with a single module
// moduleNameMapper: {},
// An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader
// modulePathIgnorePatterns: [],
// Activates notifications for test results
// notify: false,
// An enum that specifies notification mode. Requires { notify: true }
// notifyMode: "failure-change",
// A preset that is used as a base for Jest's configuration
// preset: null,
// Run tests from one or more projects
// projects: null,
// Use this configuration option to add custom reporters to Jest
// reporters: undefined,
// Automatically reset mock state between every test
// resetMocks: false,
// Reset the module registry before running each individual test
// resetModules: false,
// A path to a custom resolver
// resolver: null,
// Automatically restore mock state between every test
// restoreMocks: false,
// The root directory that Jest should scan for tests and modules within
rootDir: 'src/newsreader/js/tests/',
// A list of paths to directories that Jest should use to search for files in
// roots: [
// "<rootDir>"
// ],
// Allows you to use a custom runner instead of Jest's default test runner
// runner: "jest-runner",
// The paths to modules that run some code to configure or set up the testing environment before each test
// setupFiles: [],
// A list of paths to modules that run some code to configure or set up the testing framework before each test
// setupFilesAfterEnv: [],
// A list of paths to snapshot serializer modules Jest should use for snapshot testing
// snapshotSerializers: [],
// The test environment that will be used for testing
testEnvironment: 'node',
// Options that will be passed to the testEnvironment
// testEnvironmentOptions: {},
// Adds a location field to test results
// testLocationInResults: false,
// The glob patterns Jest uses to detect test files
// testMatch: [
// "**/__tests__/**/*.[jt]s?(x)",
// "**/?(*.)+(spec|test).[tj]s?(x)"
// ],
// An array of regexp pattern strings that are matched against all test paths, matched tests are skipped
// testPathIgnorePatterns: [
// "/node_modules/"
// ],
// The regexp pattern or array of patterns that Jest uses to detect test files
// testRegex: [],
// This option allows the use of a custom results processor
// testResultsProcessor: null,
// This option allows use of a custom test runner
// testRunner: "jasmine2",
// This option sets the URL for the jsdom environment. It is reflected in properties such as location.href
// testURL: "http://localhost",
// Setting this value to "fake" allows the use of fake timers for functions such as "setTimeout"
// timers: "real",
// A map from regular expressions to paths to transformers
// transform: null,
// An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation
// transformIgnorePatterns: [
// "/node_modules/"
// ],
// An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them
// unmockedModulePathPatterns: undefined,
// Indicates whether each individual test should be reported during the run
// verbose: null,
// An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode
// watchPathIgnorePatterns: [],
// Whether to use watchman for file crawling
// watchman: true,
};

2419
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -5,7 +5,10 @@
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"lint": "prettier \"src/newsreader/js/**/*.js\" --check", "lint": "prettier \"src/newsreader/js/**/*.js\" --check",
"format": "prettier \"src/newsreader/js/**/*.js\" --write" "format": "prettier \"src/newsreader/js/**/*.js\" --write",
"watch": "npx gulp watch",
"test": "jest",
"test:watch": "npm test -- --watch"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -16,6 +19,7 @@
"dependencies": { "dependencies": {
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"object-assign": "^4.1.1",
"react-redux": "^7.1.3", "react-redux": "^7.1.3",
"redux": "^4.0.5", "redux": "^4.0.5",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
@ -32,18 +36,23 @@
"@babel/preset-env": "^7.7.7", "@babel/preset-env": "^7.7.7",
"@babel/register": "^7.7.7", "@babel/register": "^7.7.7",
"@babel/runtime": "^7.7.7", "@babel/runtime": "^7.7.7",
"babel-jest": "^24.9.0",
"babelify": "^10.0.0", "babelify": "^10.0.0",
"browserify": "^16.5.0", "browserify": "^16.5.0",
"del": "^5.1.0", "del": "^5.1.0",
"fetch-mock": "^8.3.1",
"gulp": "^4.0.2", "gulp": "^4.0.2",
"gulp-babel": "^8.0.0", "gulp-babel": "^8.0.0",
"gulp-cli": "^2.2.0", "gulp-cli": "^2.2.0",
"gulp-concat": "^2.6.1", "gulp-concat": "^2.6.1",
"gulp-sass": "^4.0.2", "gulp-sass": "^4.0.2",
"jest": "^24.9.0",
"node-fetch": "^2.6.0",
"node-sass": "^4.13.0", "node-sass": "^4.13.0",
"prettier": "^1.19.1", "prettier": "^1.19.1",
"react": "^16.12.0", "react": "^16.12.0",
"react-dom": "^16.12.0", "react-dom": "^16.12.0",
"redux-mock-store": "^1.5.4",
"vinyl-buffer": "^1.0.1", "vinyl-buffer": "^1.0.1",
"vinyl-source-stream": "^2.0.0" "vinyl-source-stream": "^2.0.0"
} }

View file

@ -66,7 +66,7 @@ export const fetchCategories = () => {
}); });
}); });
setTimeout(dispatch, 500, receiveRules(rules)); dispatch(receiveRules(rules));
}); });
}; };
}; };
@ -88,7 +88,7 @@ export const fetchCategory = category => {
dispatch(receiveCategory({ ...json })); dispatch(receiveCategory({ ...json }));
if (category.unread === 0) { if (category.unread === 0) {
dispatch(fetchRulesByCategory(category)); return dispatch(fetchRulesByCategory(category));
} }
}); });
}; };

View file

@ -1,4 +1,4 @@
import { RULE_TYPE } from '../constants.js'; import { RULE_TYPE, CATEGORY_TYPE } from '../constants.js';
export const SELECT_POST = 'SELECT_POST'; export const SELECT_POST = 'SELECT_POST';
export const UNSELECT_POST = 'UNSELECT_POST'; export const UNSELECT_POST = 'UNSELECT_POST';
@ -9,14 +9,19 @@ export const REQUEST_POSTS = 'REQUEST_POSTS';
export const MARK_POST_READ = 'MARK_POST_READ'; export const MARK_POST_READ = 'MARK_POST_READ';
export const selectPost = post => ({ export const requestPosts = () => ({ type: REQUEST_POSTS });
type: SELECT_POST,
post, export const receivePosts = (posts, next) => ({
type: RECEIVE_POSTS,
posts,
next,
}); });
export const unSelectPost = () => ({ export const receivePost = post => ({ type: RECEIVE_POST, post });
type: UNSELECT_POST,
}); export const selectPost = post => ({ type: SELECT_POST, post });
export const unSelectPost = () => ({ type: UNSELECT_POST });
export const postRead = (post, section) => ({ export const postRead = (post, section) => ({
type: MARK_POST_READ, type: MARK_POST_READ,
@ -26,7 +31,6 @@ export const postRead = (post, section) => ({
export const markPostRead = (post, token) => { export const markPostRead = (post, token) => {
return (dispatch, getState) => { return (dispatch, getState) => {
const { rules } = getState();
const { selected } = getState(); const { selected } = getState();
const url = `/api/posts/${post.id}/`; const url = `/api/posts/${post.id}/`;
@ -50,19 +54,6 @@ export const markPostRead = (post, token) => {
}; };
}; };
export const receivePosts = json => ({
type: RECEIVE_POSTS,
posts: json.items,
next: json.next,
});
export const receivePost = post => ({
type: RECEIVE_POST,
post,
});
export const requestPosts = () => ({ type: REQUEST_POSTS });
export const fetchPostsBySection = (section, page = false) => { export const fetchPostsBySection = (section, page = false) => {
return dispatch => { return dispatch => {
if (section.unread === 0) { if (section.unread === 0) {
@ -73,12 +64,13 @@ export const fetchPostsBySection = (section, page = false) => {
let url = null; let url = null;
switch ('category' in section) { switch (section.type) {
case true: case RULE_TYPE:
url = page ? page : `/api/rules/${section.id}/posts/?read=false`; url = page ? page : `/api/rules/${section.id}/posts/?read=false`;
break; break;
default: case CATEGORY_TYPE:
url = page ? page : `/api/categories/${section.id}/posts/?read=false`; url = page ? page : `/api/categories/${section.id}/posts/?read=false`;
break;
} }
return fetch(url) return fetch(url)
@ -90,14 +82,14 @@ export const fetchPostsBySection = (section, page = false) => {
posts[post.id] = post; posts[post.id] = post;
}); });
dispatch(receivePosts({ items: posts, next: json.next })); dispatch(receivePosts(posts, json.next));
}) })
.catch(error => { .catch(error => {
if (error instanceof TypeError) { if (error instanceof TypeError) {
console.log(`Unable to parse posts from request: ${error}`); console.log(`Unable to parse posts from request: ${error}`);
} }
dispatch(receivePosts({ items: {}, next: null })); dispatch(receivePosts({}, null));
}); });
}; };
}; };

View file

@ -49,28 +49,26 @@ export const fetchRule = rule => {
// fetch & update category info when the rule is read // fetch & update category info when the rule is read
if (rule.unread === 0) { if (rule.unread === 0) {
dispatch(fetchCategory({ ...category })); return dispatch(fetchCategory({ ...category }));
} }
}); });
}; };
}; };
export const fetchRulesByCategory = category => { export const fetchRulesByCategory = category => {
return (dispatch, getState) => { return dispatch => {
dispatch(requestRules()); dispatch(requestRules());
return fetch(`/api/categories/${category.id}/rules/`) return fetch(`/api/categories/${category.id}/rules/`)
.then(response => response.json()) .then(response => response.json())
.then(responseData => { .then(responseData => {
dispatch(receiveRules());
const rules = {}; const rules = {};
responseData.forEach(rule => { responseData.forEach(rule => {
rules[rule.id] = { ...rule }; rules[rule.id] = { ...rule };
}); });
setTimeout(dispatch, 500, receiveRules(rules)); dispatch(receiveRules(rules));
}); });
}; };
}; };

View file

@ -35,7 +35,7 @@ const markCategoryRead = (category, token) => {
.then(response => response.json()) .then(response => response.json())
.then(updatedCategory => { .then(updatedCategory => {
dispatch(receiveCategory({ ...updatedCategory })); dispatch(receiveCategory({ ...updatedCategory }));
dispatch( return dispatch(
markSectionRead({ markSectionRead({
...category, ...category,
...updatedCategory, ...updatedCategory,
@ -70,11 +70,11 @@ const markRuleRead = (rule, token) => {
}; };
}; };
export const markRead = (selected, token) => { export const markRead = (section, token) => {
switch ('category' in selected) { switch (section.type) {
case true: case RULE_TYPE:
return markRuleRead(selected, token); return markRuleRead(section, token);
default: case CATEGORY_TYPE:
return markCategoryRead(selected, token); return markCategoryRead(section, token);
} }
}; };

View file

@ -19,7 +19,7 @@ class CategoryItem extends React.Component {
const category = this.props.category; const category = this.props.category;
this.props.selectCategory(category); this.props.selectCategory(category);
this.props.fetchPostsBySection(category); this.props.fetchPostsBySection({ ...category, type: CATEGORY_TYPE });
this.props.fetchCategory(category); this.props.fetchCategory(category);
} }

View file

@ -12,7 +12,7 @@ class RuleItem extends React.Component {
const rule = { ...this.props.rule }; const rule = { ...this.props.rule };
this.props.selectRule(rule); this.props.selectRule(rule);
this.props.fetchPostsBySection(rule); this.props.fetchPostsBySection({ ...rule, type: RULE_TYPE });
this.props.fetchRule(rule); this.props.fetchRule(rule);
} }

View file

@ -18,23 +18,15 @@ export const posts = (state = { ...defaultState }, action) => {
case RECEIVE_POSTS: case RECEIVE_POSTS:
return { return {
...state, ...state,
type: RECEIVE_POSTS,
isFetching: false, isFetching: false,
items: { ...state.items, ...action.posts }, items: { ...state.items, ...action.posts },
}; };
case REQUEST_POSTS: case REQUEST_POSTS:
return { return { ...state, isFetching: true };
...state,
type: REQUEST_POSTS,
isFetching: true,
};
case RECEIVE_POST: case RECEIVE_POST:
const items = { ...state.items, [action.post.id]: { ...action.post } };
return { return {
...state, ...state,
items: items, items: { ...state.items, [action.post.id]: { ...action.post } },
type: RECEIVE_POST,
}; };
case MARK_SECTION_READ: case MARK_SECTION_READ:
const updatedPosts = {}; const updatedPosts = {};
@ -66,7 +58,6 @@ export const posts = (state = { ...defaultState }, action) => {
...updatedPosts, ...updatedPosts,
}, },
}; };
default: default:
return state; return state;
} }

View file

@ -26,7 +26,7 @@ export const selected = (state = { ...defaultState }, action) => {
if (state.item.clicks >= 2) { if (state.item.clicks >= 2) {
return { return {
...state, ...state,
item: { ...action.section, clicks: 0 }, item: { ...action.section, clicks: 1 },
next: false, next: false,
lastReached: false, lastReached: false,
}; };
@ -43,7 +43,7 @@ export const selected = (state = { ...defaultState }, action) => {
return { return {
...state, ...state,
item: { ...action.section, clicks: 0 }, item: { ...action.section, clicks: 1 },
next: false, next: false,
lastReached: false, lastReached: false,
}; };

View file

@ -0,0 +1,250 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/categories.js';
import * as constants from '../../../pages/homepage/constants.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('category actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to select a category', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const expectedAction = {
section: { ...category, type: constants.CATEGORY_TYPE },
type: actions.SELECT_CATEGORY,
};
expect(actions.selectCategory(category)).toEqual(expectedAction);
});
it('should create an action to receive a category', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const expectedAction = {
type: actions.RECEIVE_CATEGORY,
category,
};
expect(actions.receiveCategory(category)).toEqual(expectedAction);
});
it('should create an action to receive multiple categories', () => {
const categories = [
{ id: 1, name: 'Test category 1', unread: 200 },
{ id: 2, name: 'Test category 2', unread: 500 },
{ id: 3, name: 'Test category 3', unread: 600 },
];
const expectedAction = {
type: actions.RECEIVE_CATEGORIES,
categories,
};
expect(actions.receiveCategories(categories)).toEqual(expectedAction);
});
it('should create an action to request a category', () => {
const expectedAction = { type: actions.REQUEST_CATEGORY };
expect(actions.requestCategory()).toEqual(expectedAction);
});
it('should create an action to request multiple categories', () => {
const expectedAction = { type: actions.REQUEST_CATEGORIES };
expect(actions.requestCategories()).toEqual(expectedAction);
});
it('should create multiple actions when fetching a category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 1138,
};
fetchMock.getOnce('/api/categories/1', {
body: { ...category, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORY },
{
type: actions.RECEIVE_CATEGORY,
category: { ...category, unread: 500 },
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
return store.dispatch(actions.fetchCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching categories', () => {
const categories = {
1: { id: 1, name: 'Tech', unread: 29 },
2: { id: 2, name: 'World news', unread: 956 },
};
const rules = {
4: {
id: 4,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 2,
unread: 345,
},
5: {
id: 5,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 7,
},
};
fetchMock
.get('/api/categories/', {
body: Object.values({ ...categories }),
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1/rules/', {
body: [{ ...rules[5] }],
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/2/rules/', {
body: [{ ...rules[4] }],
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORIES },
{ type: actions.RECEIVE_CATEGORIES, categories },
{ type: ruleActions.REQUEST_RULES },
{ type: ruleActions.RECEIVE_RULES, rules },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
return store.dispatch(actions.fetchCategories()).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching a category which is read', () => {
const category = {
id: 1,
name: 'Tech',
unread: 0,
};
const rules = {
1: {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
2: {
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
};
fetchMock
.get('/api/categories/1', {
body: { ...category, unread: 500 },
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1/rules/', {
body: Object.values({ ...rules }),
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_CATEGORY },
{
type: actions.RECEIVE_CATEGORY,
category: { ...category, unread: 500 },
},
{ type: ruleActions.REQUEST_RULES },
{ type: ruleActions.RECEIVE_RULES, rules: { ...rules } },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: constants.CATEGORY_TYPE, clicks: 2 },
next: false,
lastReached: false,
post: {},
},
});
return store.dispatch(actions.fetchCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create no actions for a category which is selected less than x', () => {
const category = {
id: 1,
name: 'Tech',
unread: 200,
};
fetchMock.getOnce('/api/categories/1', {
body: { ...category, unread: 100 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: constants.CATEGORY_TYPE, clicks: 1 },
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [];
store.dispatch(actions.fetchCategory(category));
expect(store.getActions()).toEqual(expectedActions);
});
});

View file

@ -0,0 +1,325 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/posts.js';
import * as constants from '../../../pages/homepage/constants.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('rule actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action request posts', () => {
const expectedAction = { type: actions.REQUEST_POSTS };
expect(actions.requestPosts()).toEqual(expectedAction);
});
it('should create an action receive a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const expectedAction = {
type: actions.RECEIVE_POST,
post,
};
expect(actions.receivePost(post)).toEqual(expectedAction);
});
it('should create an action to select a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const expectedAction = {
type: actions.SELECT_POST,
post,
};
expect(actions.selectPost(post)).toEqual(expectedAction);
});
it('should create an action to unselect a post', () => {
const expectedAction = { type: actions.UNSELECT_POST };
expect(actions.unSelectPost()).toEqual(expectedAction);
});
it('should create an action mark a post read', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
type: actions.MARK_POST_READ,
section: rule,
post,
};
expect(actions.postRead(post, rule)).toEqual(expectedAction);
});
it('should create multiple actions to mark post read', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.patchOnce('/api/posts/2067/', {
body: { ...post, read: true },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: rule,
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [
{
type: actions.RECEIVE_POST,
post: { ...post, read: true },
},
{
type: actions.MARK_POST_READ,
post: { ...post, read: true },
section: rule,
},
];
return store.dispatch(actions.markPostRead(post, 'TOKEN')).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions to fetch posts by rule', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
type: constants.RULE_TYPE,
};
fetchMock.getOnce('/api/rules/4/posts/?read=false', {
body: {
count: 2,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
previous: null,
results: Object.values({ ...posts }),
},
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
},
];
return store.dispatch(actions.fetchPostsBySection(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions to fetch posts by category', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
};
const category = {
id: 1,
name: 'Tech',
unread: 2,
type: constants.CATEGORY_TYPE,
};
fetchMock.getOnce('/api/categories/1/posts/?read=false', {
body: {
count: 2,
next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
previous: null,
results: Object.values({ ...posts }),
},
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
posts,
},
];
return store.dispatch(actions.fetchPostsBySection(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create no actions when fetching posts and section is read', () => {
const rule = {
id: 4,
name: 'Ars Technica',
unread: 0,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [];
store.dispatch(actions.fetchPostsBySection(rule));
expect(store.getActions()).toEqual(expectedActions);
});
});

View file

@ -0,0 +1,254 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('rule actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to select a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
section: { ...rule, type: constants.RULE_TYPE },
type: actions.SELECT_RULE,
};
expect(actions.selectRule(rule)).toEqual(expectedAction);
});
it('should create an action to request a rule', () => {
const expectedAction = { type: actions.REQUEST_RULE };
expect(actions.requestRule()).toEqual(expectedAction);
});
it('should create an action to request multiple rules', () => {
const expectedAction = { type: actions.REQUEST_RULES };
expect(actions.requestRules()).toEqual(expectedAction);
});
it('should create an action to receive a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const expectedAction = {
type: actions.RECEIVE_RULE,
rule,
};
expect(actions.receiveRule(rule)).toEqual(expectedAction);
});
it('should create an action to receive multiple rules', () => {
const rules = {
1: {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
2: {
id: 2,
name: 'Test rule 2',
unread: 50,
category: 1,
url: 'https://xkcd.com/atom.xml',
favicon: null,
},
};
const expectedAction = {
type: actions.RECEIVE_RULES,
rules,
};
expect(actions.receiveRules(rules)).toEqual(expectedAction);
});
it('should create multiple actions to fetch a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.getOnce('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_RULE },
{ type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } },
];
return store.dispatch(actions.fetchRule(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should not create not create actions when rule is clicked less then twice', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
fetchMock.getOnce('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...rule, type: constants.RULE_TYPE, clicks: 1 },
next: false,
lastReached: false,
post: {},
},
});
const expectedActions = [];
store.dispatch(actions.fetchRule(rule));
expect(store.getActions()).toEqual(expectedActions);
});
it('should create multiple actions to fetch a rule wich is read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 0,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const category = {
id: 1,
name: 'Test category',
unread: 500,
};
fetchMock
.get('/api/rules/1', {
body: { ...rule, unread: 500 },
headers: { 'content-type': 'application/json' },
})
.get('/api/categories/1', {
body: { ...category, unread: 2000 },
headers: { 'content-type': 'application/json' },
});
const store = mockStore({
categories: { items: { 1: { ...category } }, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
const expectedActions = [
{ type: actions.REQUEST_RULE },
{ type: actions.RECEIVE_RULE, rule: { ...rule, unread: 500 } },
{ type: categoryActions.REQUEST_CATEGORY },
{
type: categoryActions.RECEIVE_CATEGORY,
category: { ...category, unread: 2000 },
},
];
return store.dispatch(actions.fetchRule(rule)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should create multiple actions when fetching rules by category', () => {
const category = {
id: 1,
name: 'Tech',
unread: 0,
};
const rules = {
1: {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
2: {
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
};
fetchMock.getOnce('/api/categories/1/rules/', {
body: Object.values({ ...rules }),
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: actions.REQUEST_RULES },
{ type: actions.RECEIVE_RULES, rules },
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {},
});
return store.dispatch(actions.fetchRulesByCategory(category)).then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,141 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import * as actions from '../../../pages/homepage/actions/selected.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
describe('category actions', () => {
afterEach(() => {
fetchMock.restore();
});
it('should create an action to mark a section read', () => {
const category = {
id: 1,
name: 'Test category',
unread: 100,
type: constants.CATEGORY_TYPE,
};
const expectedAction = {
section: { ...category, type: constants.CATEGORY_TYPE },
type: actions.MARK_SECTION_READ,
};
expect(actions.markSectionRead(category)).toEqual(expectedAction);
});
it('should mark a category as read', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const rules = {
1: {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
},
2: {
id: 2,
name: 'Hacker News',
url: 'https://news.ycombinator.com/rss',
favicon: 'https://news.ycombinator.com/favicon.ico',
category: 1,
unread: 350,
},
};
fetchMock.postOnce('/api/categories/1/read/', {
body: { ...category, unread: 0 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: categoryActions.REQUEST_CATEGORY },
{
type: categoryActions.RECEIVE_CATEGORY,
category: { ...category, unread: 0 },
},
{
type: actions.MARK_SECTION_READ,
section: {
...category,
unread: 0,
rules: rules,
type: constants.CATEGORY_TYPE,
},
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { ...rules }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...category, type: actions.CATEGORY_TYPE },
next: false,
lastReached: false,
post: {},
},
});
return store
.dispatch(actions.markRead({ ...category, type: constants.CATEGORY_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
it('should mark a rule as read', () => {
const rule = {
id: 1,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 1,
unread: 200,
};
fetchMock.postOnce('/api/rules/1/read/', {
body: { ...rule, unread: 0 },
headers: { 'content-type': 'application/json' },
});
const expectedActions = [
{ type: ruleActions.REQUEST_RULE },
{
type: ruleActions.RECEIVE_RULE,
rule: { ...rule, unread: 0 },
},
{
type: actions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
},
];
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: { [rule.id]: { ...rule } }, isFetching: false },
posts: { items: {}, isFetching: false },
selected: {
item: { ...rule, type: constants.RULE_TYPE },
next: false,
lastReached: false,
post: {},
},
});
return store
.dispatch(actions.markRead({ ...rule, type: constants.RULE_TYPE }, 'TOKEN'))
.then(() => {
expect(store.getActions()).toEqual(expectedActions);
});
});
});

View file

@ -0,0 +1,214 @@
import { categories as reducer } from '../../../pages/homepage/reducers/categories.js';
import * as actions from '../../../pages/homepage/actions/categories.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('category reducer', () => {
it('should return default state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return state after receiving category', () => {
const receivedCategory = { id: 9, name: 'Tech', unread: 291 };
const action = { type: actions.RECEIVE_CATEGORY, category: receivedCategory };
const expectedState = {
...defaultState,
items: { [receivedCategory.id]: receivedCategory },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving multiple categories', () => {
const receivedCategories = {
0: { id: 9, name: 'Tech', unread: 291 },
1: { id: 2, name: 'World news', unread: 444 },
};
const action = { type: actions.RECEIVE_CATEGORIES, categories: receivedCategories };
const items = {};
Object.values({ ...receivedCategories }).forEach(category => {
items[category.id] = category;
});
const expectedState = { ...defaultState, items };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after requesting a category', () => {
const action = { type: actions.REQUEST_CATEGORY };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after requesting multiple categories', () => {
const action = { type: actions.REQUEST_CATEGORIES };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a post read with a category selected', () => {
const category = {
id: 9,
name: 'Tech',
unread: 291,
};
const post = {
id: 2091,
remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51249208',
title: 'China coronavirus spread is accelerating, Xi Jinping warns',
body:
'China\'s president tells a high-level meeting that the country faces a "grave situation".',
author: null,
publication_date: '2020-01-26T05:54:14Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51249208',
rule: 4,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
post,
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 290 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a post read with a rule selected', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const post = {
id: 2182,
remote_identifier: 'https://arstechnica.com/?p=1648871',
title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey',
body:
'It should be renamed and fitted with a real driver-monitoring system, he says.',
author: 'Jonathan M. Gitlin',
publication_date: '2020-01-25T18:34:20Z',
url: 'https://arstechnica.com/?p=1648871',
rule: 1,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...rule, type: constants.RULE_TYPE },
post,
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 432 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read with a category', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 0 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read with a rule', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 211,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = {
...defaultState,
items: { [category.id]: { ...category } },
};
const expectedState = {
...defaultState,
items: {
[category.id]: { ...category, unread: 222 },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,304 @@
import { posts as reducer } from '../../../pages/homepage/reducers/posts.js';
import * as actions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('post actions', () => {
it('should return state after requesting posts', () => {
const action = { type: actions.REQUEST_POSTS };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: actions.RECEIVE_POST,
post,
};
const expectedState = {
...defaultState,
isFetching: false,
items: { [post.id]: post },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving posts', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
};
const action = {
type: actions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
};
const expectedState = {
...defaultState,
isFetching: false,
items: posts,
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a rule read', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 5,
read: false,
},
4637: {
id: 4637,
remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
title: "Coronavirus: Whole world 'must take action', warns WHO",
body:
'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.',
author: null,
publication_date: '2020-01-29T19:08:25Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
},
4638: {
id: 4638,
remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305',
title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'",
body:
'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.',
author: null,
publication_date: '2020-01-29T18:27:56Z',
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
},
};
const rule = {
id: 5,
name: 'Ars Technica',
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
category: 9,
unread: 544,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = { ...defaultState, items: { ...posts } };
const expectedState = {
...defaultState,
isFetching: false,
items: {
...posts,
2067: { ...posts[2067], read: true },
2141: { ...posts[2141], read: true },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a category read', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 5,
read: false,
},
4637: {
id: 4637,
remote_identifier: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
title: "Coronavirus: Whole world 'must take action', warns WHO",
body:
'The World Health Organization will hold a further emergency meeting on the coronavirus on Thursday.',
author: null,
publication_date: '2020-01-29T19:08:25Z',
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
},
4638: {
id: 4638,
remote_identifier: 'https://www.bbc.co.uk/news/world-europe-51294305',
title: "Coronavirus: French Asians hit back at racism with 'I'm not a virus'",
body:
'The coronavirus outbreak in Wuhan prompts French Asians to complain of a backlash against them.',
author: null,
publication_date: '2020-01-29T18:27:56Z',
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
},
4589: {
id: 4589,
remote_identifier: 'https://tweakers.net/nieuws/162878',
title: 'Analyse: Nintendo verdiende miljard dollar aan mobiele games',
body:
'Nintendo heeft tot nu toe een miljard dollar verdiend aan mobiele games, zo heeft SensorTower becijferd. Daarbij gaat het om inkomsten uit de App Store van Apple en Play Store van Google. De game die het meeste opbracht is Fire Emblem Heroes.<img alt="" src="http://feeds.feedburner.com/~r/tweakers/mixed/~4/Yn_we8WaUeA">',
author: 'Arnoud Wokke',
publication_date: '2020-01-29T19:03:01Z',
url:
'https://tweakers.net/nieuws/162878/analyse-nintendo-verdiende-miljard-dollar-aan-mobiele-games.html',
rule: 7,
read: false,
},
4594: {
id: 4594,
remote_identifier: 'https://tweakers.net/nieuws/162870',
title: 'Samsung kondigt eerste tablet met 5g aan',
body:
'Samsung heef zijn eerste tablet met 5g aangekondigd. Het gaat om een variant op de al bestaande Galaxy Tab S6, maar dan voorzien van Qualcomm X50-modem. Er gingen al maanden geruchten over de release van de tablet.<img alt="" src="http://feeds.feedburner.com/~r/tweakers/mixed/~4/IfEYe00sm3U">',
author: 'Arnoud Wokke',
publication_date: '2020-01-29T16:29:40Z',
url:
'https://tweakers.net/nieuws/162870/samsung-kondigt-eerste-tablet-met-5g-aan.html',
rule: 7,
read: false,
},
};
const rules = {
4: {
id: 4,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 8,
unread: 321,
},
5: {
id: 4,
name: 'BBC',
url: 'http://feeds.bbci.co.uk/news/world/rss.xml',
favicon:
'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png',
category: 8,
unread: 632,
},
};
const category = {
id: 8,
name: 'News',
unread: 953,
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: {
...category,
type: constants.CATEGORY_TYPE,
rules,
},
};
const state = { ...defaultState, items: { ...posts } };
const expectedState = {
...defaultState,
isFetching: false,
items: {
...posts,
2067: { ...posts[2067], read: true },
2141: { ...posts[2141], read: true },
4637: { ...posts[4637], read: true },
4638: { ...posts[4638], read: true },
},
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,181 @@
import { rules as reducer } from '../../../pages/homepage/reducers/rules.js';
import * as actions from '../../../pages/homepage/actions/rules.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as selectedActions from '../../../pages/homepage/actions/selected.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { items: {}, isFetching: false };
describe('category reducer', () => {
it('should return default state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return after requesting a rule', () => {
const action = { type: actions.REQUEST_RULE };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return after requesting multiple rules', () => {
const action = { type: actions.REQUEST_RULES };
const expectedState = { ...defaultState, isFetching: true };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = { type: actions.RECEIVE_RULE, rule };
const expectedState = {
...defaultState,
items: {
[rule.id]: rule,
},
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving multiple rules', () => {
const rules = {
1: {
id: 1,
name: 'Test rule',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
2: {
id: 2,
name: 'Another Test rule',
unread: 444,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
},
};
const action = { type: actions.RECEIVE_RULES, rules };
const expectedState = { ...defaultState, items: { ...rules } };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after marking a post read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const post = {
id: 2182,
remote_identifier: 'https://arstechnica.com/?p=1648871',
title: 'Tesla needs to fix Autopilot safety flaws, demands Senator Markey',
body:
'It should be renamed and fitted with a real driver-monitoring system, he says.',
author: 'Jonathan M. Gitlin',
publication_date: '2020-01-25T18:34:20Z',
url: 'https://arstechnica.com/?p=1648871',
rule: 1,
read: false,
};
const action = {
type: postActions.MARK_POST_READ,
section: { ...rule, type: constants.RULE_TYPE },
post,
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 99 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a category read', () => {
const category = {
id: 9,
name: 'Tech',
unread: 433,
};
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...category, type: constants.CATEGORY_TYPE },
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 0 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a rule read', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: selectedActions.MARK_SECTION_READ,
section: { ...rule, type: constants.RULE_TYPE },
};
const state = {
...defaultState,
items: { [rule.id]: rule },
};
const expectedState = {
...defaultState,
items: { [rule.id]: { ...rule, unread: 0 } },
};
expect(reducer(state, action)).toEqual(expectedState);
});
});

View file

@ -0,0 +1,399 @@
import { selected as reducer } from '../../../pages/homepage/reducers/selected.js';
import * as actions from '../../../pages/homepage/actions/selected.js';
import * as categoryActions from '../../../pages/homepage/actions/categories.js';
import * as postActions from '../../../pages/homepage/actions/posts.js';
import * as ruleActions from '../../../pages/homepage/actions/rules.js';
import * as constants from '../../../pages/homepage/constants.js';
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
describe('selected reducer', () => {
it('should return state', () => {
expect(reducer(undefined, {})).toEqual(defaultState);
});
it('should return state after selecting a category', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after selecting a rule', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after selecting a category twice', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...category, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 2 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a rule twice', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...rule, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 2 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a category the third time', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...category, clicks: 2 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a rule the third time', () => {
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...rule, clicks: 2 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting different (rule) section type', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: ruleActions.SELECT_RULE,
section: rule,
};
const state = {
...defaultState,
item: { ...category, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...rule, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting different (category) section type', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
const rule = {
id: 1,
name: 'Test rule',
unread: 100,
category: 9,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: categoryActions.SELECT_CATEGORY,
section: category,
};
const state = {
...defaultState,
item: { ...rule, clicks: 1 },
};
const expectedState = {
...defaultState,
item: { ...category, clicks: 1 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after receiving posts', () => {
const posts = {
2067: {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
},
2141: {
id: 2141,
remote_identifier: 'https://arstechnica.com/?p=1648757',
title: 'The most complete brain map ever is here: A flys “connectome”',
body:
'It took 12 years and at least $40 million to chart a region about 250µm across.',
author: 'WIRED',
publication_date: '2020-01-25T11:06:46Z',
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
},
};
const action = {
type: postActions.RECEIVE_POSTS,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
posts,
};
const expectedState = {
...defaultState,
next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
lastReached: false,
};
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after receiving a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.RECEIVE_POST,
post: { ...post, rule: 6 },
};
const state = { ...defaultState, post };
const expectedState = { ...defaultState, post: { ...post, rule: 6 } };
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after selecting a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.SELECT_POST,
post,
};
const expectedState = { ...defaultState, post };
expect(reducer(undefined, action)).toEqual(expectedState);
});
it('should return state after unselecting a post', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const action = {
type: postActions.UNSELECT_POST,
post,
};
const state = { ...defaultState, post };
const expectedState = { ...defaultState, post: {} };
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a post read', () => {
const post = {
id: 2067,
remote_identifier: 'https://arstechnica.com/?p=1648607',
title:
'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge',
body:
'"Stale-reference manipulation," 300-character file names, and a clash between worlds.',
author: 'Kyle Orland',
publication_date: '2020-01-24T19:50:12Z',
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
};
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
type: postActions.MARK_POST_READ,
section: rule,
post,
};
const state = {
...defaultState,
item: rule,
};
const expectedState = {
...defaultState,
item: { ...rule, unread: 99 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
it('should return state after marking a section read', () => {
const rule = {
id: 4,
name: 'Ars Technica',
unread: 100,
category: 1,
url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
favicon: 'https://cdn.arstechnica.net/favicon.ico',
};
const action = {
section: { ...rule },
type: actions.MARK_SECTION_READ,
};
const state = { ...defaultState, item: { ...rule, clicks: 2 } };
const expectedState = {
...defaultState,
item: { ...rule, unread: 0, clicks: 0 },
};
expect(reducer(state, action)).toEqual(expectedState);
});
});