about summary refs log tree commit diff
path: root/spec
diff options
context:
space:
mode:
Diffstat (limited to 'spec')
-rw-r--r--spec/javascript/.eslintrc.yml3
-rw-r--r--spec/javascript/components/avatar.test.js44
-rw-r--r--spec/javascript/components/avatar_overlay.test.js36
-rw-r--r--spec/javascript/components/button.test.js72
-rw-r--r--spec/javascript/components/display_name.test.js18
-rw-r--r--spec/javascript/components/emoji_index.test.js111
-rw-r--r--spec/javascript/components/emojify.test.js61
-rw-r--r--spec/javascript/components/features/ui/components/column.test.js30
-rw-r--r--spec/javascript/setup.js15
-rw-r--r--spec/lib/feed_manager_spec.rb111
-rw-r--r--spec/workers/scheduler/feed_cleanup_scheduler_spec.rb8
11 files changed, 103 insertions, 406 deletions
diff --git a/spec/javascript/.eslintrc.yml b/spec/javascript/.eslintrc.yml
deleted file mode 100644
index 6db2a46c5..000000000
--- a/spec/javascript/.eslintrc.yml
+++ /dev/null
@@ -1,3 +0,0 @@
----
-env:
-  mocha: true
diff --git a/spec/javascript/components/avatar.test.js b/spec/javascript/components/avatar.test.js
deleted file mode 100644
index 34949f2b5..000000000
--- a/spec/javascript/components/avatar.test.js
+++ /dev/null
@@ -1,44 +0,0 @@
-import React from 'react';
-import Avatar from '../../../app/javascript/mastodon/components/avatar';
-
-import { expect } from 'chai';
-import { render } from 'enzyme';
-import { fromJS }  from 'immutable';
-
-describe('<Avatar />', () => {
-  const account = fromJS({
-    username: 'alice',
-    acct: 'alice',
-    display_name: 'Alice',
-    avatar: '/animated/alice.gif',
-    avatar_static: '/static/alice.jpg',
-  });
-
-  const size = 100;
-  const animated = render(<Avatar account={account} animate size={size} />);
-  const still = render(<Avatar account={account} size={size} />);
-
-  // Autoplay
-  xit('renders a div element with the given src as background', () => {
-    expect(animated.find('div')).to.have.style('background-image', `url(${account.get('avatar')})`);
-  });
-
-  xit('renders a div element of the given size', () => {
-    ['width', 'height'].map((attr) => {
-      expect(animated.find('div')).to.have.style(attr, `${size}px`);
-    });
-  });
-
-  // Still
-  xit('renders a div element with the given static src as background if not autoplay', () => {
-    expect(still.find('div')).to.have.style('background-image', `url(${account.get('avatar_static')})`);
-  });
-
-  xit('renders a div element of the given size if not autoplay', () => {
-    ['width', 'height'].map((attr) => {
-      expect(still.find('div')).to.have.style(attr, `${size}px`);
-    });
-  });
-
-  // TODO add autoplay test if possible
-});
diff --git a/spec/javascript/components/avatar_overlay.test.js b/spec/javascript/components/avatar_overlay.test.js
deleted file mode 100644
index fe1d3a012..000000000
--- a/spec/javascript/components/avatar_overlay.test.js
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import AvatarOverlay from '../../../app/javascript/mastodon/components/avatar_overlay';
-
-import { expect } from 'chai';
-import { render } from 'enzyme';
-import { fromJS }  from 'immutable';
-
-describe('<Avatar />', () => {
-  const account = fromJS({
-    username: 'alice',
-    acct: 'alice',
-    display_name: 'Alice',
-    avatar: '/animated/alice.gif',
-    avatar_static: '/static/alice.jpg',
-  });
-
-  const friend = fromJS({
-    username: 'eve',
-    acct: 'eve@blackhat.lair',
-    display_name: 'Evelyn',
-    avatar: '/animated/eve.gif',
-    avatar_static: '/static/eve.jpg',
-  });
-
-  const overlay = render(<AvatarOverlay account={account} friend={friend} />);
-
-  xit('renders account static src as base of overlay avatar', () => {
-    expect(overlay.find('.account__avatar-overlay-base'))
-      .to.have.style('background-image', `url(${account.get('avatar_static')})`);
-  });
-
-  xit('renders friend static src as overlay of overlay avatar', () => {
-    expect(overlay.find('.account__avatar-overlay-overlay'))
-      .to.have.style('background-image', `url(${friend.get('avatar_static')})`);
-  });
-});
diff --git a/spec/javascript/components/button.test.js b/spec/javascript/components/button.test.js
deleted file mode 100644
index d2cd0b4e7..000000000
--- a/spec/javascript/components/button.test.js
+++ /dev/null
@@ -1,72 +0,0 @@
-import React from 'react';
-import Button from '../../../app/javascript/mastodon/components/button';
-
-import { expect } from 'chai';
-import { shallow } from 'enzyme';
-import sinon from 'sinon';
-
-describe('<Button />', () => {
-  xit('renders a button element', () => {
-    const wrapper = shallow(<Button />);
-    expect(wrapper).to.match('button');
-  });
-
-  xit('renders the given text', () => {
-    const text = 'foo';
-    const wrapper = shallow(<Button text={text} />);
-    expect(wrapper.find('button')).to.have.text(text);
-  });
-
-  it('handles click events using the given handler', () => {
-    const handler = sinon.spy();
-    const wrapper = shallow(<Button onClick={handler} />);
-    wrapper.find('button').simulate('click');
-    expect(handler.calledOnce).to.equal(true);
-  });
-
-  it('does not handle click events if props.disabled given', () => {
-    const handler = sinon.spy();
-    const wrapper = shallow(<Button onClick={handler} disabled />);
-    wrapper.find('button').simulate('click');
-    expect(handler.called).to.equal(false);
-  });
-
-  xit('renders a disabled attribute if props.disabled given', () => {
-    const wrapper = shallow(<Button disabled />);
-    expect(wrapper.find('button')).to.be.disabled();
-  });
-
-  xit('renders the children', () => {
-    const children = <p>children</p>;
-    const wrapper = shallow(<Button>{children}</Button>);
-    expect(wrapper.find('button')).to.contain(children);
-  });
-
-  xit('renders the props.text instead of children', () => {
-    const text = 'foo';
-    const children = <p>children</p>;
-    const wrapper = shallow(<Button text={text}>{children}</Button>);
-    expect(wrapper.find('button')).to.have.text(text);
-    expect(wrapper.find('button')).to.not.contain(children);
-  });
-
-  xit('renders style="display: block; width: 100%;" if props.block given', () => {
-    const wrapper = shallow(<Button block />);
-    expect(wrapper.find('button')).to.have.className('button--block');
-  });
-
-  xit('renders style="display: inline-block; width: auto;" by default', () => {
-    const wrapper = shallow(<Button />);
-    expect(wrapper.find('button')).to.not.have.className('button--block');
-  });
-
-  xit('adds class "button-secondary" if props.secondary given', () => {
-    const wrapper = shallow(<Button secondary />);
-    expect(wrapper.find('button')).to.have.className('button-secondary');
-  });
-
-  xit('does not add class "button-secondary" by default', () => {
-    const wrapper = shallow(<Button />);
-    expect(wrapper.find('button')).to.not.have.className('button-secondary');
-  });
-});
diff --git a/spec/javascript/components/display_name.test.js b/spec/javascript/components/display_name.test.js
deleted file mode 100644
index 97a111894..000000000
--- a/spec/javascript/components/display_name.test.js
+++ /dev/null
@@ -1,18 +0,0 @@
-import React from 'react';
-import DisplayName from '../../../app/javascript/mastodon/components/display_name';
-
-import { expect } from 'chai';
-import { render } from 'enzyme';
-import { fromJS }  from 'immutable';
-
-describe('<DisplayName />', () => {
-  xit('renders display name + account name', () => {
-    const account = fromJS({
-      username: 'bar',
-      acct: 'bar@baz',
-      display_name_html: '<p>Foo</p>',
-    });
-    const wrapper = render(<DisplayName account={account} />);
-    expect(wrapper).to.have.text('Foo @bar@baz');
-  });
-});
diff --git a/spec/javascript/components/emoji_index.test.js b/spec/javascript/components/emoji_index.test.js
deleted file mode 100644
index cdb50cb8c..000000000
--- a/spec/javascript/components/emoji_index.test.js
+++ /dev/null
@@ -1,111 +0,0 @@
-import { expect } from 'chai';
-import { search } from '../../../app/javascript/mastodon/features/emoji/emoji_mart_search_light';
-import { emojiIndex } from 'emoji-mart';
-import { pick } from 'lodash';
-
-const trimEmojis = emoji => pick(emoji, ['id', 'unified', 'native', 'custom']);
-
-// hack to fix https://github.com/chaijs/type-detect/issues/98
-// see: https://github.com/chaijs/type-detect/issues/98#issuecomment-325010785
-import jsdom from 'jsdom';
-global.window = new jsdom.JSDOM().window;
-global.document = window.document;
-global.HTMLElement = window.HTMLElement;
-
-describe('emoji_index', () => {
-
-  it('should give same result for emoji_index_light and emoji-mart', () => {
-    let expected = [{
-      id: 'pineapple',
-      unified: '1f34d',
-      native: '๐Ÿ',
-    }];
-    expect(search('pineapple').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('pineapple').map(trimEmojis)).to.deep.equal(expected);
-  });
-
-  it('orders search results correctly', () => {
-    let expected = [{
-      id: 'apple',
-      unified: '1f34e',
-      native: '๐ŸŽ',
-    }, {
-      id: 'pineapple',
-      unified: '1f34d',
-      native: '๐Ÿ',
-    }, {
-      id: 'green_apple',
-      unified: '1f34f',
-      native: '๐Ÿ',
-    }, {
-      id: 'iphone',
-      unified: '1f4f1',
-      native: '๐Ÿ“ฑ',
-    }];
-    expect(search('apple').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('apple').map(trimEmojis)).to.deep.equal(expected);
-  });
-
-  it('handles custom emoji', () => {
-    let custom = [{
-      id: 'mastodon',
-      name: 'mastodon',
-      short_names: ['mastodon'],
-      text: '',
-      emoticons: [],
-      keywords: ['mastodon'],
-      imageUrl: 'http://example.com',
-      custom: true,
-    }];
-    search('', { custom });
-    emojiIndex.search('', { custom });
-    let expected = [ { id: 'mastodon', custom: true } ];
-    expect(search('masto').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('masto').map(trimEmojis)).to.deep.equal(expected);
-  });
-
-  it('should filter only emojis we care about, exclude pineapple', () => {
-    let emojisToShowFilter = (unified) => unified !== '1F34D';
-    expect(search('apple', { emojisToShowFilter }).map((obj) => obj.id))
-      .not.to.contain('pineapple');
-    expect(emojiIndex.search('apple', { emojisToShowFilter }).map((obj) => obj.id))
-      .not.to.contain('pineapple');
-  });
-
-  it('can include/exclude categories', () => {
-    expect(search('flag', { include: ['people'] }))
-      .to.deep.equal([]);
-    expect(emojiIndex.search('flag', { include: ['people'] }))
-      .to.deep.equal([]);
-  });
-
-  it('does an emoji whose unified name is irregular', () => {
-    let expected = [{
-      'id': 'water_polo',
-      'unified': '1f93d',
-      'native': '๐Ÿคฝ',
-    }, {
-      'id': 'man-playing-water-polo',
-      'unified': '1f93d-200d-2642-fe0f',
-      'native': '๐Ÿคฝโ€โ™‚๏ธ',
-    }, {
-      'id': 'woman-playing-water-polo',
-      'unified': '1f93d-200d-2640-fe0f',
-      'native': '๐Ÿคฝโ€โ™€๏ธ',
-    }];
-    expect(search('polo').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('polo').map(trimEmojis)).to.deep.equal(expected);
-  });
-
-  it('can search for thinking_face', () => {
-    let expected = [ { id: 'thinking_face', unified: '1f914', native: '๐Ÿค”' } ];
-    expect(search('thinking_fac').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('thinking_fac').map(trimEmojis)).to.deep.equal(expected);
-  });
-
-  it('can search for woman-facepalming', () => {
-    let expected = [ { id: 'woman-facepalming', unified: '1f926-200d-2640-fe0f', native: '๐Ÿคฆโ€โ™€๏ธ' } ];
-    expect(search('woman-facep').map(trimEmojis)).to.deep.equal(expected);
-    expect(emojiIndex.search('woman-facep').map(trimEmojis)).deep.equal(expected);
-  });
-});
diff --git a/spec/javascript/components/emojify.test.js b/spec/javascript/components/emojify.test.js
deleted file mode 100644
index 3105c8e3f..000000000
--- a/spec/javascript/components/emojify.test.js
+++ /dev/null
@@ -1,61 +0,0 @@
-import { expect } from 'chai';
-import emojify from '../../../app/javascript/mastodon/features/emoji/emoji';
-
-describe('emojify', () => {
-  it('ignores unknown shortcodes', () => {
-    expect(emojify(':foobarbazfake:')).to.equal(':foobarbazfake:');
-  });
-
-  it('ignores shortcodes inside of tags', () => {
-    expect(emojify('<p data-foo=":smile:"></p>')).to.equal('<p data-foo=":smile:"></p>');
-  });
-
-  it('works with unclosed tags', () => {
-    expect(emojify('hello>')).to.equal('hello>');
-    expect(emojify('<hello')).to.equal('<hello');
-  });
-
-  it('works with unclosed shortcodes', () => {
-    expect(emojify('smile:')).to.equal('smile:');
-    expect(emojify(':smile')).to.equal(':smile');
-  });
-
-  it('does unicode', () => {
-    expect(emojify('\uD83D\uDC69\u200D\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66')).to.equal(
-      '<img draggable="false" class="emojione" alt="๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆโ€๐Ÿ‘ฆ" title=":woman-woman-boy-boy:" src="/emoji/1f469-200d-1f469-200d-1f466-200d-1f466.svg" />');
-    expect(emojify('๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง')).to.equal(
-      '<img draggable="false" class="emojione" alt="๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง" title=":man-woman-girl-girl:" src="/emoji/1f468-200d-1f469-200d-1f467-200d-1f467.svg" />');
-    expect(emojify('๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ')).to.equal('<img draggable="false" class="emojione" alt="๐Ÿ‘ฉโ€๐Ÿ‘ฉโ€๐Ÿ‘ฆ" title=":woman-woman-boy:" src="/emoji/1f469-200d-1f469-200d-1f466.svg" />');
-    expect(emojify('\u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" />');
-  });
-
-  it('does multiple unicode', () => {
-    expect(emojify('\u2757 #\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" />');
-    expect(emojify('\u2757#\uFE0F\u20E3')).to.equal(
-      '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /><img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" />');
-    expect(emojify('\u2757 #\uFE0F\u20E3 \u2757')).to.equal(
-      '<img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" /> <img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" />');
-    expect(emojify('foo \u2757 #\uFE0F\u20E3 bar')).to.equal(
-      'foo <img draggable="false" class="emojione" alt="โ—" title=":exclamation:" src="/emoji/2757.svg" /> <img draggable="false" class="emojione" alt="#๏ธโƒฃ" title=":hash:" src="/emoji/23-20e3.svg" /> bar');
-  });
-
-  it('ignores unicode inside of tags', () => {
-    expect(emojify('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>')).to.equal('<p data-foo="\uD83D\uDC69\uD83D\uDC69\uD83D\uDC66"></p>');
-  });
-
-  it('does multiple emoji properly (issue 5188)', () => {
-    expect(emojify('๐Ÿ‘Œ๐ŸŒˆ๐Ÿ’•')).to.equal('<img draggable="false" class="emojione" alt="๐Ÿ‘Œ" title=":ok_hand:" src="/emoji/1f44c.svg" /><img draggable="false" class="emojione" alt="๐ŸŒˆ" title=":rainbow:" src="/emoji/1f308.svg" /><img draggable="false" class="emojione" alt="๐Ÿ’•" title=":two_hearts:" src="/emoji/1f495.svg" />');
-    expect(emojify('๐Ÿ‘Œ ๐ŸŒˆ ๐Ÿ’•')).to.equal('<img draggable="false" class="emojione" alt="๐Ÿ‘Œ" title=":ok_hand:" src="/emoji/1f44c.svg" /> <img draggable="false" class="emojione" alt="๐ŸŒˆ" title=":rainbow:" src="/emoji/1f308.svg" /> <img draggable="false" class="emojione" alt="๐Ÿ’•" title=":two_hearts:" src="/emoji/1f495.svg" />');
-  });
-
-  it('does an emoji that has no shortcode', () => {
-    expect(emojify('๐Ÿ•‰๏ธ')).to.equal('<img draggable="false" class="emojione" alt="๐Ÿ•‰๏ธ" title="" src="/emoji/1f549.svg" />');
-  });
-
-  it('does an emoji whose filename is irregular', () => {
-    expect(emojify('โ†™๏ธ')).to.equal('<img draggable="false" class="emojione" alt="โ†™๏ธ" title=":arrow_lower_left:" src="/emoji/2199.svg" />');
-  });
-
-});
diff --git a/spec/javascript/components/features/ui/components/column.test.js b/spec/javascript/components/features/ui/components/column.test.js
deleted file mode 100644
index 4491d6e19..000000000
--- a/spec/javascript/components/features/ui/components/column.test.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import { expect } from 'chai';
-import { mount } from 'enzyme';
-import sinon from 'sinon';
-import React from 'react';
-import Column from '../../../../../../app/javascript/mastodon/features/ui/components/column';
-import ColumnHeader from '../../../../../../app/javascript/mastodon/features/ui/components/column_header';
-
-describe('<Column />', () => {
-  describe('<ColumnHeader /> click handler', () => {
-    beforeEach(() => {
-      global.requestAnimationFrame = sinon.spy();
-    });
-
-    it('runs the scroll animation if the column contains scrollable content', () => {
-      const wrapper = mount(
-        <Column heading='notifications'>
-          <div className='scrollable' />
-        </Column>
-      );
-      wrapper.find(ColumnHeader).simulate('click');
-      expect(global.requestAnimationFrame.called).to.equal(true);
-    });
-
-    it('does not try to scroll if there is no scrollable content', () => {
-      const wrapper = mount(<Column heading='notifications' />);
-      wrapper.find(ColumnHeader).simulate('click');
-      expect(global.requestAnimationFrame.called).to.equal(false);
-    });
-  });
-});
diff --git a/spec/javascript/setup.js b/spec/javascript/setup.js
deleted file mode 100644
index ab8a36b95..000000000
--- a/spec/javascript/setup.js
+++ /dev/null
@@ -1,15 +0,0 @@
-import { JSDOM } from 'jsdom';
-import Enzyme from 'enzyme';
-import Adapter from 'enzyme-adapter-react-16';
-
-Enzyme.configure({ adapter: new Adapter() });
-
-const { window } = new JSDOM('', {
-  userAgent: 'node.js',
-});
-
-Object.keys(window).forEach(property => {
-  if (typeof global[property] === 'undefined') {
-    global[property] = window[property];
-  }
-});
diff --git a/spec/lib/feed_manager_spec.rb b/spec/lib/feed_manager_spec.rb
index 923894ccb..0f97a579e 100644
--- a/spec/lib/feed_manager_spec.rb
+++ b/spec/lib/feed_manager_spec.rb
@@ -211,6 +211,22 @@ RSpec.describe FeedManager do
         expect(FeedManager.instance.push('type', account, reblogs.last)).to be false
       end
 
+      it 'does not save a new reblog of a multiply-reblogged-then-unreblogged status' do
+        account   = Fabricate(:account)
+        reblogged = Fabricate(:status)
+        reblogs = 3.times.map { Fabricate(:status, reblog: reblogged) }
+
+        # Accept the reblogs
+        FeedManager.instance.push('type', account, reblogs[0])
+        FeedManager.instance.push('type', account, reblogs[1])
+
+        # Unreblog the first one
+        FeedManager.instance.unpush('type', account, reblogs[0])
+
+        # The last reblog should still be ignored
+        expect(FeedManager.instance.push('type', account, reblogs.last)).to be false
+      end
+
       it 'saves a new reblog of a long-ago-reblogged status' do
         account = Fabricate(:account)
         reblogged = Fabricate(:status)
@@ -230,34 +246,101 @@ RSpec.describe FeedManager do
     end
   end
 
+  describe '#trim' do
+    let(:receiver) { Fabricate(:account) }
+
+    it 'cleans up reblog tracking keys' do
+      reblogged      = Fabricate(:status)
+      status         = Fabricate(:status, reblog: reblogged)
+      another_status = Fabricate(:status, reblog: reblogged)
+      reblogs_key    = FeedManager.instance.key('type', receiver.id, 'reblogs')
+      reblog_set_key = FeedManager.instance.key('type', receiver.id, "reblogs:#{reblogged.id}")
+
+      FeedManager.instance.push('type', receiver, status)
+      FeedManager.instance.push('type', receiver, another_status)
+
+      # We should have a tracking set and an entry in reblogs.
+      expect(Redis.current.exists(reblog_set_key)).to be true
+      expect(Redis.current.zrange(reblogs_key, 0, -1)).to eq [reblogged.id.to_s]
+
+      # Push everything off the end of the feed.
+      FeedManager::MAX_ITEMS.times do
+        FeedManager.instance.push('type', receiver, Fabricate(:status))
+      end
+
+      # `trim` should be called automatically, but do it anyway, as
+      # we're testing `trim`, not side effects of `push`.
+      FeedManager.instance.trim('type', receiver.id)
+
+      # We should not have any reblog tracking data.
+      expect(Redis.current.exists(reblog_set_key)).to be false
+      expect(Redis.current.zrange(reblogs_key, 0, -1)).to be_empty
+    end
+  end
+
   describe '#unpush' do
-    it 'leaves a reblogged status when deleting the reblog' do
-      account = Fabricate(:account)
+    let(:receiver) { Fabricate(:account) }
+
+    it 'leaves a reblogged status if original was on feed' do
       reblogged = Fabricate(:status)
-      status = Fabricate(:status, reblog: reblogged)
+      status    = Fabricate(:status, reblog: reblogged)
 
-      FeedManager.instance.push('type', account, status)
+      FeedManager.instance.push('type', receiver, reblogged)
+      FeedManager::REBLOG_FALLOFF.times { FeedManager.instance.push('type', receiver, Fabricate(:status)) }
+      FeedManager.instance.push('type', receiver, status)
+
+      # The reblogging status should show up under normal conditions.
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to include(status.id.to_s)
+
+      FeedManager.instance.unpush('type', receiver, status)
+
+      # Restore original status
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to_not include(status.id.to_s)
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to include(reblogged.id.to_s)
+    end
+
+    it 'removes a reblogged status if it was only reblogged once' do
+      reblogged = Fabricate(:status)
+      status    = Fabricate(:status, reblog: reblogged)
+
+      FeedManager.instance.push('type', receiver, status)
 
       # The reblogging status should show up under normal conditions.
-      expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [status.id.to_s]
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [status.id.to_s]
 
-      FeedManager.instance.unpush('type', account, status)
+      FeedManager.instance.unpush('type', receiver, status)
 
-      # Because we couldn't tell if the status showed up any other way,
-      # we had to stick the reblogged status in by itself.
-      expect(Redis.current.zrange("feed:type:#{account.id}", 0, -1)).to eq [reblogged.id.to_s]
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to be_empty
+    end
+
+    it 'leaves a multiply-reblogged status if another reblog was in feed' do
+      reblogged = Fabricate(:status)
+      reblogs   = 3.times.map { Fabricate(:status, reblog: reblogged) }
+
+      reblogs.each do |reblog|
+        FeedManager.instance.push('type', receiver, reblog)
+      end
+
+      # The reblogging status should show up under normal conditions.
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [reblogs.first.id.to_s]
+
+      reblogs[0...-1].each do |reblog|
+        FeedManager.instance.unpush('type', receiver, reblog)
+      end
+
+      expect(Redis.current.zrange("feed:type:#{receiver.id}", 0, -1)).to eq [reblogs.last.id.to_s]
     end
 
     it 'sends push updates' do
-      account = Fabricate(:account)
-      status = Fabricate(:status)
-      FeedManager.instance.push('type', account, status)
+      status  = Fabricate(:status)
+
+      FeedManager.instance.push('type', receiver, status)
 
       allow(Redis.current).to receive_messages(publish: nil)
-      FeedManager.instance.unpush('type', account, status)
+      FeedManager.instance.unpush('type', receiver, status)
 
       deletion = Oj.dump(event: :delete, payload: status.id.to_s)
-      expect(Redis.current).to have_received(:publish).with("timeline:#{account.id}", deletion)
+      expect(Redis.current).to have_received(:publish).with("timeline:#{receiver.id}", deletion)
     end
   end
 end
diff --git a/spec/workers/scheduler/feed_cleanup_scheduler_spec.rb b/spec/workers/scheduler/feed_cleanup_scheduler_spec.rb
index b8487b03f..7fae680ba 100644
--- a/spec/workers/scheduler/feed_cleanup_scheduler_spec.rb
+++ b/spec/workers/scheduler/feed_cleanup_scheduler_spec.rb
@@ -9,14 +9,18 @@ describe Scheduler::FeedCleanupScheduler do
   it 'clears feeds of inactives' do
     Redis.current.zadd(feed_key_for(inactive_user), 1, 1)
     Redis.current.zadd(feed_key_for(active_user), 1, 1)
+    Redis.current.zadd(feed_key_for(inactive_user, 'reblogs'), 2, 2)
+    Redis.current.sadd(feed_key_for(inactive_user, 'reblogs:2'), 3)
 
     subject.perform
 
     expect(Redis.current.zcard(feed_key_for(inactive_user))).to eq 0
     expect(Redis.current.zcard(feed_key_for(active_user))).to eq 1
+    expect(Redis.current.exists(feed_key_for(inactive_user, 'reblogs'))).to be false
+    expect(Redis.current.exists(feed_key_for(inactive_user, 'reblogs:2'))).to be false
   end
 
-  def feed_key_for(user)
-    FeedManager.instance.key(:home, user.account_id)
+  def feed_key_for(user, subtype = nil)
+    FeedManager.instance.key(:home, user.account_id, subtype)
   end
 end