0
0
mirror of https://github.com/naturalcrit/homebrewery.git synced 2026-08-06 06:47:38 +00:00

Merge branch 'master' into fixSafeHTML

This commit is contained in:
G.Ambatte
2026-08-01 16:53:50 +12:00
committed by GitHub
12 changed files with 1168 additions and 1226 deletions
+1 -1
View File
@@ -308,7 +308,7 @@ const CodeEditor = forwardRef(
view.dispatch({ view.dispatch({
effects : themeCompartment.reconfigure(themeExtension), effects : themeCompartment.reconfigure(themeExtension),
}); });
}, [editorTheme]); }, [editorTheme, tab]);
useEffect(()=>{ useEffect(()=>{
//rebuild syntax highlight when changing tab or renderer //rebuild syntax highlight when changing tab or renderer
@@ -1,33 +1,44 @@
/* eslint max-lines: ["error", { "max": 300 }] */ /* eslint max-lines: ["error", { "max": 300 }] */
import { keymap } from '@codemirror/view'; import { keymap } from '@codemirror/view';
import { undo, redo, indentMore, deleteLine } from '@codemirror/commands'; import { undo, redo, indentMore, indentLess, deleteLine } from '@codemirror/commands';
import { EditorSelection } from '@codemirror/state';
import { Prec } from '@codemirror/state'; import { Prec } from '@codemirror/state';
const insertTab = (view)=>{ const insertTab = (view)=>{
const { from, to } = view.state.selection.main; // If any selection spans multiple lines, delegates to CodeMirror's indentMore
// Otherwise inserts two spaces at each cursor/selection
const shouldIndent = view.state.selection.ranges.some((range)=>view.state.doc.lineAt(range.from).number !==
view.state.doc.lineAt(range.to).number
);
if(shouldIndent) return indentMore(view);
const changes = [];
for (const range of view.state.selection.ranges) {
changes.push({
from : range.from,
to : range.to,
insert : ' ' // Insert two spaces, not a tab char!
});
}
// Create a transaction so we can map old positions to
// their new positions after the edits are applied
const mappedChanges = view.state.update({ changes });
view.dispatch({ view.dispatch({
changes : { from, to, insert: ' ' }, changes,
selection : { anchor: from + 2 } selection : EditorSelection.create(
view.state.selection.ranges.map((range)=>EditorSelection.cursor(
mappedChanges.changes.mapPos(range.from, 1) + 2
)
)
)
}); });
return true; return true;
}; };
const indentLess = (view)=>{
const { from, to } = view.state.selection.main;
const lines = [];
for (let l = view.state.doc.lineAt(from).number; l <= view.state.doc.lineAt(to).number; l++) {
const line = view.state.doc.line(l);
const match = line.text.match(/^ {1,2}/); // match up to 2 spaces
if(match) {
lines.push({ from: line.from, to: line.from + match[0].length, insert: '' });
}
}
if(lines.length > 0) view.dispatch({ changes: lines });
return true;
};
const wrapSelection = (prefix, suffix)=>(view)=>{ const wrapSelection = (prefix, suffix)=>(view)=>{
const changes = []; const changes = [];
@@ -169,16 +180,16 @@ const newPage = (view)=>{
}; };
export const generalKeymap = Prec.high(keymap.of([ export const generalKeymap = Prec.high(keymap.of([
{ key: 'Tab', run: insertTab }, { key: 'Tab', run: insertTab }, //runs indentMore if multiple lines selected in a single selection
{ key: 'Mod-z', run: undo }, //i think it may be unnecessary { key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-z', run: undo }, //it may be unnecessary
{ key: 'Mod-Shift-z', run: redo }, { key: 'Mod-Shift-z', run: redo },
{ key: 'Mod-y', run: redo }, { key: 'Mod-y', run: redo }, //user asked, so double keybind
{ key: 'Mod-d', run: deleteLine }, { key: 'Mod-d', run: deleteLine }, //annoyingly overrides "selectNextOccurrence" because users asked
])); ]));
export const markdownKeymap = Prec.highest(keymap.of([ export const markdownKeymap = Prec.highest(keymap.of([
//{ key: 'Shift-Tab', run: indentMore },
{ key: 'Shift-Tab', run: indentLess },
{ key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold { key: 'Mod-b', run: wrapSelection('**', '**') }, // makeBold
{ key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic { key: 'Mod-i', run: wrapSelection('*', '*') }, // makeItalic
{ key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline { key: 'Mod-u', run: wrapSelection('<u>', '</u>') }, // makeUnderline
@@ -44,6 +44,7 @@ const MetadataEditor = createReactClass({
getInitialState : function(){ getInitialState : function(){
return { return {
isOwner : global.account?.username && global.account?.username === this.props.metadata?.authors[0],
showThumbnail : true showThumbnail : true
}; };
}, },
@@ -144,6 +145,15 @@ const MetadataEditor = createReactClass({
}); });
}, },
handleDeleteAuthor : function(author){
if(!confirm('Are you sure you want to remove this author? They will lose all edit access to this brew, and it will dissapear from their userpage.')) return;
if(!this.props.metadata.authors.includes(author)) return;
this.props.onChange({
...this.props.metadata,
authors : this.props.metadata.authors.filter((a)=>a !== author)
});
},
renderPublish : function(){ renderPublish : function(){
if(this.props.metadata.published){ if(this.props.metadata.published){
return <button className='unpublish' onClick={()=>this.handlePublish(false)}> return <button className='unpublish' onClick={()=>this.handlePublish(false)}>
@@ -170,16 +180,54 @@ const MetadataEditor = createReactClass({
}, },
renderAuthors : function(){ renderAuthors : function(){
let text = 'None.'; const authors = this.props.metadata.authors;
if(this.props.metadata.authors && this.props.metadata.authors.length){ if(!this.state.isOwner || authors.length < 2) return (
text = this.props.metadata.authors.join(', '); <div className='field authors'>
}
return <div className='field authors'>
<label>authors</label> <label>authors</label>
<div className='value'> <div className='value'>
{text} {authors.length > 0 && (
<a href={`/user/${authors[0]}`} className='author-link' target="_blank" title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}{authors.length > 1 && ', '}
</a>
)}
{authors.length > 1 && authors.slice(1).map((author, i)=>(
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${author}'s profile in a new tab`}>
{author}{i+2 < authors.length && ', '}
</a>
))}
</div> </div>
</div>; </div>
);
return (
<div className='field authors'>
<label>Authors</label>
<ul className='list'>
{authors.length > 0 && (
<li className='tag owner' title='Owner'>
<a href={`/user/${authors[0]}`} className='author-link' title={`Owner - Click to open ${authors[0]}'s profile in a new tab`}>
{authors[0]}
</a>
</li>
)}
{authors.length > 1 && authors.slice(1).map((author, i)=>(
<li className='tag author' key={i + 1} title='Author'>
<a href={`/user/${author}`} className='author-link' title={`Author - Click to open ${authors[0]}'s profile in a new tab`}>
{author}
</a>
<button
onClick={()=>this.handleDeleteAuthor(author)}
className='delete'
title={`Remove ${author} as an author`}
>
<i className='fa fa-times fa-fw' />
</button>
</li>
))}
</ul>
</div>
);
}, },
renderThemeDropdown : function(){ renderThemeDropdown : function(){
@@ -173,7 +173,51 @@
.colorButton(@red); .colorButton(@red);
} }
} }
.authors.field .value { line-height : 1.5em; } .authors.field {
.tag {
font-weight:300;
transition:background-color 0.2s;
&.owner {
position: relative;
background-color:@silverLight;
min-width:25px;
display:grid;
place-items:center;
font-weight: 900;
&::after {
content: "\f521";
font-family: "Font Awesome 6 Free";
color:gold;
position: absolute;
top: 0;
left: 0;
width:15px;
height:15px;
rotate:-25deg;
translate:-30% -50%;
transform: scaleY(0.7);
}
}
&:has(button) a {
padding-right:5px;
}
&:has(button:hover) {
background:#d97d7d;
}
button {
color:@red;
}
}
a {
color:black;
text-decoration:unset;
}
}
.themes.field { .themes.field {
& .dropdown-container { & .dropdown-container {
@@ -275,13 +319,17 @@
} }
.tag { .tag {
padding : 0.3em; padding : 0.35em;
margin : 2px; margin : 2px;
font-size : 0.9em; font-size : 0.95em;
background-color : #DDDDDD; background-color : #DDDDDD;
border-radius : 0.5em; border-radius : 0.5em;
.icon { #groupedIcon; } .icon { #groupedIcon; }
button {
cursor : pointer;
}
} }
.input-group { .input-group {
+2 -2
View File
@@ -61,10 +61,10 @@ const Nav = {
{icon} {icon}
</a>; </a>;
} else { } else {
return <div {...props} className={classes} onClick={this.handleClick} > return <button {...props} className={classes} onClick={this.handleClick} >
{this.props.children} {this.props.children}
{icon} {icon}
</div>; </button>;
} }
} }
}), }),
+1 -1
View File
@@ -320,7 +320,7 @@ const EditPage = (props)=>{
// #5 - No unsaved changes, and has never been saved, hide the button // #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved) if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>; return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED // DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>; return <Nav.item className='save saved'>saved</Nav.item>;
+1 -1
View File
@@ -164,7 +164,7 @@ const HomePage =(props)=>{
// #5 - No unsaved changes, and has never been saved, hide the button // #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved) if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>; return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED // DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>; return <Nav.item className='save saved'>saved</Nav.item>;
+1 -1
View File
@@ -207,7 +207,7 @@ const NewPage = (props)=>{
// #5 - No unsaved changes, and has never been saved, hide the button // #5 - No unsaved changes, and has never been saved, hide the button
if(neverSaved) if(neverSaved)
return <Nav.item className='save neverSaved'>save now</Nav.item>; return <Nav.item className='save neverSaved' disabled={true}>save now</Nav.item>;
// DEFAULT - No unsaved changes, show SAVED // DEFAULT - No unsaved changes, show SAVED
return <Nav.item className='save saved'>saved</Nav.item>; return <Nav.item className='save saved'>saved</Nav.item>;
+866 -1021
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -105,7 +105,7 @@
"@dmsnell/diff-match-patch": "^1.1.0", "@dmsnell/diff-match-patch": "^1.1.0",
"@googleapis/drive": "^20.2.0", "@googleapis/drive": "^20.2.0",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"@oddbird/css-anchor-positioning": "^0.9.0", "@oddbird/css-anchor-positioning": "^0.10.1",
"@sanity/diff-match-patch": "^3.2.0", "@sanity/diff-match-patch": "^3.2.0",
"@vitejs/plugin-react": "^5.1.2", "@vitejs/plugin-react": "^5.1.2",
"body-parser": "^2.2.0", "body-parser": "^2.2.0",
@@ -146,7 +146,7 @@
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7", "react-dom": "^19.2.7",
"react-frame-component": "^5.3.2", "react-frame-component": "^5.3.2",
"react-router": "^7.17.0", "react-router": "^8.3.0",
"sanitize-filename": "1.6.4", "sanitize-filename": "1.6.4",
"superagent": "^10.2.1" "superagent": "^10.2.1"
}, },
@@ -167,6 +167,6 @@
"stylelint-config-recess-order": "^7.7.0", "stylelint-config-recess-order": "^7.7.0",
"stylelint-config-recommended": "^18.0.0", "stylelint-config-recommended": "^18.0.0",
"supertest": "^7.1.4", "supertest": "^7.1.4",
"vite": "^7.3.1" "vite": "^8.1.5"
} }
} }
-3
View File
@@ -195,7 +195,6 @@ const api = {
next(); next();
}; };
}, },
getCSS : async (req, res)=>{ getCSS : async (req, res)=>{
const { brew } = req; const { brew } = req;
if(!brew) return res.status(404).send(''); if(!brew) return res.status(404).send('');
@@ -208,7 +207,6 @@ const api = {
}); });
return res.status(200).send(brew.style); return res.status(200).send(brew.style);
}, },
mergeBrewText : (brew)=>{ mergeBrewText : (brew)=>{
let text = brew.text; let text = brew.text;
if(brew.style !== undefined) { if(brew.style !== undefined) {
@@ -226,7 +224,6 @@ const api = {
`${text}`; `${text}`;
return text; return text;
}, },
getGoodBrewTitle : (text)=>{ getGoodBrewTitle : (text)=>{
const tokens = Markdown.marked.lexer(text); const tokens = Markdown.marked.lexer(text);
return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title') return (tokens.find((token)=>token.type === 'heading' || token.type === 'paragraph')?.text || 'No Title')
+148 -155
View File
@@ -203,7 +203,6 @@ describe('Tests for api', ()=>{
expect(id).toEqual('abcdefghij'); expect(id).toEqual('abcdefghij');
}); });
}); });
describe('getBrew', ()=>{ describe('getBrew', ()=>{
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew })); const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
const notFoundError = { HBErrorCode: '05', message: 'Brew not found', name: 'BrewLoad Error', status: 404, accessType: 'share', brewId: '1' }; const notFoundError = { HBErrorCode: '05', message: 'Brew not found', name: 'BrewLoad Error', status: 404, accessType: 'share', brewId: '1' };
@@ -380,7 +379,68 @@ describe('Tests for api', ()=>{
await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' }); await expect(fn(req, null, next)).rejects.toEqual({ 'HBErrorCode': '51', 'brewId': '1', 'brewTitle': 'test brew', 'code': 404, 'message': 'brew locked' });
}); });
}); });
describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
});
it('should return 404 when brew has no style content', async ()=>{
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
it('should return 404 when brew does not exist', async ()=>{
const testBrew = { };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('mergeBrewText', ()=>{ describe('mergeBrewText', ()=>{
it('should set metadata and no style if it is not present', ()=>{ it('should set metadata and no style if it is not present', ()=>{
const result = api.mergeBrewText({ const result = api.mergeBrewText({
@@ -437,7 +497,6 @@ hello yes i am css
brew`); brew`);
}); });
}); });
describe('exclusion methods', ()=>{ describe('exclusion methods', ()=>{
it('excludePropsFromUpdate removes the correct keys', ()=>{ it('excludePropsFromUpdate removes the correct keys', ()=>{
const sent = Object.assign({}, googleBrew); const sent = Object.assign({}, googleBrew);
@@ -474,7 +533,6 @@ brew`);
expect(result.pageCount).toBe(1); expect(result.pageCount).toBe(1);
}); });
}); });
describe('beforeNewSave', ()=>{ describe('beforeNewSave', ()=>{
it('sets the title if none', ()=>{ it('sets the title if none', ()=>{
const brew = { const brew = {
@@ -516,7 +574,6 @@ brew`);
expect(hbBrew.text).toEqual('merged'); expect(hbBrew.text).toEqual('merged');
}); });
}); });
describe('newGoogleBrew', ()=>{ describe('newGoogleBrew', ()=>{
it('should call the correct methods', ()=>{ it('should call the correct methods', ()=>{
api.excludeGoogleProps = jest.fn(()=>'newBrew'); api.excludeGoogleProps = jest.fn(()=>'newBrew');
@@ -530,7 +587,6 @@ brew`);
expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew'); expect(google.newGoogleBrew).toHaveBeenCalledWith('client', 'newBrew');
}); });
}); });
describe('newBrew', ()=>{ describe('newBrew', ()=>{
it('should set up a default brew via Homebrew model', async ()=>{ it('should set up a default brew via Homebrew model', async ()=>{
await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res); await api.newBrew({ body: { text: 'asdf' }, query: {}, account: { username: 'test user' } }, res);
@@ -620,17 +676,6 @@ brew`);
}); });
}); });
}); });
describe('deleteGoogleBrew', ()=>{
it('should check auth and delete brew', async ()=>{
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
expect(result).toBe(true);
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
});
});
describe('Theme bundle', ()=>{ describe('Theme bundle', ()=>{
it('should return Theme Bundle for a User Theme', async ()=>{ it('should return Theme Bundle for a User Theme', async ()=>{
const brews = { const brews = {
@@ -774,7 +819,94 @@ brew`);
status : 422 }); status : 422 });
}); });
}); });
describe('updateBrew', ()=>{
it('should return error on version mismatch', async ()=>{
const brewFromClient = { version: 1 };
const brewFromServer = { version: 1000, text: '' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
it('should return error on hash mismatch', async ()=>{
const brewFromClient = { version: 1, hash: '1234' };
const brewFromServer = { version: 1, text: 'test' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
// Commenting this one out for now, since we are no longer throwing this error while we monitor
// it('should return error on applying patches', async ()=>{
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
// const req = {
// brew : brewFromServer,
// body : brewFromClient,
// };
// let err;
// try {
// await api.updateBrew(req, res);
// } catch (e) {
// err = e;
// }
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
// });
it('should save brew, no ID', async ()=>{
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
model.save = jest.fn((brew)=>{return brew;});
const req = {
brew : brewFromServer,
body : brewFromClient,
query : { saveToGoogle: false, removeFromGoogle: false }
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
_id : '1',
description : 'Test Description',
hash : '098f6bcd4621d373cade4e832627b4f6',
title : 'Test Title',
version : 2
})
);
});
});
describe('deleteGoogleBrew', ()=>{
it('should check auth and delete brew', async ()=>{
const result = await api.deleteGoogleBrew({ username: 'test user' }, 'id', 'editId', res);
expect(result).toBe(true);
expect(google.authCheck).toHaveBeenCalledWith({ username: 'test user' }, expect.objectContaining({}));
expect(google.deleteGoogleBrew).toHaveBeenCalledWith('client', 'id', 'editId');
});
});
describe('deleteBrew', ()=>{ describe('deleteBrew', ()=>{
it('should handle case where fetching the brew returns an error', async ()=>{ it('should handle case where fetching the brew returns an error', async ()=>{
api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; }); api.getBrew = jest.fn(()=>async ()=>{ throw { message: 'err', HBErrorCode: '02' }; });
@@ -995,68 +1127,7 @@ brew`);
expect(saved.googleId).toEqual(brew.googleId); expect(saved.googleId).toEqual(brew.googleId);
}); });
}); });
describe('Get CSS', ()=>{
it('should return brew style content as CSS text', async ()=>{
const testBrew = { title: 'test brew', text: '```css\n\nI Have a style!\n```\n\n' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style', '\nI Have a style!\n');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith('\nI Have a style!\n');
expect(res.set).toHaveBeenCalledWith({
'Cache-Control' : 'no-cache',
'Content-Type' : 'text/css'
});
});
it('should return 404 when brew has no style content', async ()=>{
const testBrew = { title: 'test brew', text: 'I don\'t have a style!' };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
it('should return 404 when brew does not exist', async ()=>{
const testBrew = { };
const toBrewPromise = (brew)=>new Promise((res)=>res({ toObject: ()=>brew }));
api.getId = jest.fn(()=>({ id: '1', googleId: undefined }));
model.get = jest.fn(()=>toBrewPromise(testBrew));
const fn = api.getBrew('share', true);
const req = { brew: {} };
const next = jest.fn();
await fn(req, null, next);
await api.getCSS(req, res);
expect(req.brew).toEqual(testBrew);
expect(req.brew).toHaveProperty('style');
expect(res.status).toHaveBeenCalledWith(404);
expect(res.send).toHaveBeenCalledWith('');
});
});
describe('Split Text, Style, and Metadata', ()=>{ describe('Split Text, Style, and Metadata', ()=>{
it('basic splitting', async ()=>{ it('basic splitting', async ()=>{
@@ -1095,82 +1166,4 @@ brew`);
}); });
}); });
describe('updateBrew', ()=>{
it('should return error on version mismatch', async ()=>{
const brewFromClient = { version: 1 };
const brewFromServer = { version: 1000, text: '' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server version is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
it('should return error on hash mismatch', async ()=>{
const brewFromClient = { version: 1, hash: '1234' };
const brewFromServer = { version: 1, text: 'test' };
const req = {
brew : brewFromServer,
body : brewFromClient
};
await api.updateBrew(req, res);
expect(req.brew.hash).toBe('098f6bcd4621d373cade4e832627b4f6');
expect(res.status).toHaveBeenCalledWith(409);
expect(res.send).toHaveBeenCalledWith('{\"message\":\"The server copy is out of sync with the saved brew. Please save your changes elsewhere, refresh, and try again.\"}');
});
// Commenting this one out for now, since we are no longer throwing this error while we monitor
// it('should return error on applying patches', async ()=>{
// const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: 'not a valid patch string' };
// const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
// const req = {
// brew : brewFromServer,
// body : brewFromClient,
// };
// let err;
// try {
// await api.updateBrew(req, res);
// } catch (e) {
// err = e;
// }
// expect(err).toEqual(Error('Invalid patch string: not a valid patch string'));
// });
it('should save brew, no ID', async ()=>{
const brewFromClient = { version: 1, hash: '098f6bcd4621d373cade4e832627b4f6', patches: '' };
const brewFromServer = { version: 1, text: 'test', title: 'Test Title', description: 'Test Description' };
model.save = jest.fn((brew)=>{return brew;});
const req = {
brew : brewFromServer,
body : brewFromClient,
query : { saveToGoogle: false, removeFromGoogle: false }
};
await api.updateBrew(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.send).toHaveBeenCalledWith(
expect.objectContaining({
_id : '1',
description : 'Test Description',
hash : '098f6bcd4621d373cade4e832627b4f6',
title : 'Test Title',
version : 2
})
);
});
});
}); });