Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions src/account_x/__tests__/unit/containsFilter.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import { describe, test, expect } from "vitest";

import {
containsFilterCondition,
containsFilterHasTooManyWildcards,
containsFilterMatches,
isEmptyContainsFilterText,
} from "../../controller/deletion/containsFilter";

describe("containsFilterMatches", () => {
test("matches a whole word regardless of case", () => {
expect(containsFilterMatches("Drinking coffee now", "Coffee")).toBe(true);
expect(containsFilterMatches("COFFEE", "coffee")).toBe(true);
});

test("matches the word inside a hashtag, mention, or punctuation", () => {
expect(containsFilterMatches("#Coffee - Fresh Cup", "Coffee")).toBe(true);
expect(containsFilterMatches("hey @Coffee!", "Coffee")).toBe(true);
expect(containsFilterMatches("(coffee)", "Coffee")).toBe(true);
expect(containsFilterMatches("#coffee\n\nnext line", "Coffee")).toBe(true);
});

test("does not match when the word is part of a longer word", () => {
expect(containsFilterMatches("@CoffeeBot hi", "Coffee")).toBe(false);
expect(containsFilterMatches("#CoffeeNow", "Coffee")).toBe(false);
expect(containsFilterMatches("uncoffee", "Coffee")).toBe(false);
});

test("a trailing star matches the rest of a word", () => {
const filter = "Coffee*";
expect(containsFilterMatches("#Coffee", filter)).toBe(true);
expect(containsFilterMatches("@CoffeeBot", filter)).toBe(true);
expect(containsFilterMatches("Coffeebla", filter)).toBe(true);
expect(containsFilterMatches("coffeeTHIS", filter)).toBe(true);
expect(containsFilterMatches("cof fee", filter)).toBe(false);
});

test("a leading star matches the start of a word", () => {
expect(containsFilterMatches("the BestCoffee", "*Coffee")).toBe(true);
expect(containsFilterMatches("Coffee", "*Coffee")).toBe(true);
expect(containsFilterMatches("CoffeeBot", "*Coffee")).toBe(false);
});

test("a star in the middle matches letters, not spaces", () => {
expect(containsFilterMatches("Now-Playing now_bot", "now*bot")).toBe(true);
expect(containsFilterMatches("nowandbot", "now*bot")).toBe(true);
expect(containsFilterMatches("now and bot", "now*bot")).toBe(false);
});

test("a term that starts with # or @ needs no word edge before it", () => {
expect(containsFilterMatches("so#Coffee", "#Coffee")).toBe(true);
expect(containsFilterMatches("hey @someone", "@someone")).toBe(true);
expect(containsFilterMatches("hey @someoneelse", "@someone")).toBe(false);
});

test("takes everything else literally, including regex and SQL wildcards", () => {
expect(containsFilterMatches("it is 100% sure", "100%")).toBe(true);
expect(containsFilterMatches("it is 1000 sure", "100%")).toBe(false);
expect(containsFilterMatches("a_b", "a_b")).toBe(true);
expect(containsFilterMatches("axb", "a_b")).toBe(false);
expect(containsFilterMatches("cost $5 (approx)", "$5 (approx)")).toBe(true);
expect(containsFilterMatches("a.b", "a.b")).toBe(true);
expect(containsFilterMatches("axb", "a.b")).toBe(false);
expect(containsFilterMatches("back\\slash", "back\\slash")).toBe(true);
});

test("matches phrases with spaces", () => {
expect(containsFilterMatches("Hot Coffee: a cup", "hot coffee")).toBe(true);
expect(containsFilterMatches("Hot Coffee", "hot coffee")).toBe(false);
});

test("handles accented and non-latin words", () => {
expect(containsFilterMatches("Café del Mar", "café")).toBe(true);
expect(containsFilterMatches("Cafés", "café")).toBe(false);
expect(containsFilterMatches("音楽 を聴く", "音楽")).toBe(true);
});

test("ignores whitespace around the filter text", () => {
expect(containsFilterMatches("#Coffee", " Coffee ")).toBe(true);
});

test("never matches null text or an empty filter", () => {
expect(containsFilterMatches(null, "Coffee")).toBe(false);
expect(containsFilterMatches("anything", "")).toBe(false);
expect(containsFilterMatches("anything", " ")).toBe(false);
expect(containsFilterMatches("anything", "*")).toBe(false);
expect(containsFilterMatches("anything", " ** ")).toBe(false);
});
});

describe("wildcard limit", () => {
test("stars in a row count as one", () => {
expect(containsFilterHasTooManyWildcards("a***b")).toBe(false);
expect(containsFilterMatches("axxb", "a***b")).toBe(true);
});

test("allows up to the limit and rejects more", () => {
expect(containsFilterHasTooManyWildcards("*a*b*c")).toBe(false);
expect(containsFilterHasTooManyWildcards("*a*b*c*")).toBe(true);
});

test("a filter over the limit matches nothing", () => {
expect(containsFilterMatches("abcde", "a*b*c*d*e")).toBe(false);
expect(containsFilterCondition("text", true, "a*b*c*d*e")).not.toBeNull();
});

test("the worst case at the limit finishes quickly", () => {
const text = "a".repeat(300) + "c";
const start = Date.now();
expect(containsFilterMatches(text, "a*a*a*b")).toBe(false);
expect(Date.now() - start).toBeLessThan(1000);
});
});

describe("isEmptyContainsFilterText", () => {
test("is empty for blanks and for stars alone", () => {
expect(isEmptyContainsFilterText("")).toBe(true);
expect(isEmptyContainsFilterText(" ")).toBe(true);
expect(isEmptyContainsFilterText("*")).toBe(true);
expect(isEmptyContainsFilterText(" * * ")).toBe(true);
});

test("is not empty when there is something to look for", () => {
expect(isEmptyContainsFilterText("a")).toBe(false);
expect(isEmptyContainsFilterText("*a*")).toBe(false);
expect(isEmptyContainsFilterText("#")).toBe(false);
});
});

describe("containsFilterCondition", () => {
test("returns null when disabled or empty", () => {
expect(containsFilterCondition("text", false, "Coffee")).toBeNull();
expect(containsFilterCondition("text", true, "")).toBeNull();
expect(containsFilterCondition("text", true, " ")).toBeNull();
expect(containsFilterCondition("text", true, "*")).toBeNull();
});

test("binds the trimmed filter text for the given column", () => {
const condition = containsFilterCondition("t.text", true, " Now* ");

expect(condition).not.toBeNull();
expect(condition!.sql).toContain("t.text");
expect(condition!.param).toBe("Now*");
});
});
126 changes: 126 additions & 0 deletions src/account_x/__tests__/unit/controller.deletion.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,94 @@ describe("XAccountController - Deletion", () => {

expect(ids).toEqual(["eligible"]);
});

test("only returns tweets containing the filter text when enabled", async () => {
controller.account!.deleteTweetsFilterEnabled = true;
controller.account!.deleteTweetsFilterText = "@Someone";

seedTweet(controller, {
tweetID: "mentions",
text: "hey @someone look at this",
});
seedTweet(controller, {
tweetID: "reply",
text: "@SOMEONE no way",
});
seedTweet(controller, {
tweetID: "unrelated",
text: "nothing to see here",
});
seedTweet(controller, {
tweetID: "retweet-of-them",
text: "RT @someone: their post",
});

const result = await controller.deleteTweetsStart();
const ids = result.tweets.map((tweet) => tweet.id).sort();

expect(ids).toEqual(["mentions", "reply"]);
});

test("matches whole words only, and a star matches the rest of a word", async () => {
seedTweet(controller, {
tweetID: "hashtag",
text: "#Coffee - Fresh Cup",
});
seedTweet(controller, { tweetID: "bot", text: "@CoffeeBot hello" });
seedTweet(controller, { tweetID: "glued", text: "coffeeTHIS" });
seedTweet(controller, { tweetID: "other", text: "nothing here" });

controller.account!.deleteTweetsFilterEnabled = true;
controller.account!.deleteTweetsFilterText = "Coffee";
const whole = await controller.deleteTweetsStart();
expect(whole.tweets.map((tweet) => tweet.id)).toEqual(["hashtag"]);

controller.account!.deleteTweetsFilterText = "Coffee*";
const starred = await controller.deleteTweetsStart();
expect(starred.tweets.map((tweet) => tweet.id).sort()).toEqual([
"bot",
"glued",
"hashtag",
]);
});

test("does not filter at all for stars alone", async () => {
seedTweet(controller, { tweetID: "a", text: "one" });
seedTweet(controller, { tweetID: "b", text: "two" });

controller.account!.deleteTweetsFilterEnabled = true;
controller.account!.deleteTweetsFilterText = "*";
const result = await controller.deleteTweetsStart();

// The page never lets this through, so it must at least not be treated
// as a filter that matches a particular post
expect(result.tweets.length).toBe(2);
});

test("treats LIKE wildcards in the filter text literally", async () => {
controller.account!.deleteTweetsFilterEnabled = true;
controller.account!.deleteTweetsFilterText = "100%";

seedTweet(controller, { tweetID: "literal", text: "I am 100% sure" });
seedTweet(controller, { tweetID: "not-literal", text: "100 percent" });

const result = await controller.deleteTweetsStart();
const ids = result.tweets.map((tweet) => tweet.id);

expect(ids).toEqual(["literal"]);
});

test("ignores the filter text when the filter is disabled", async () => {
controller.account!.deleteTweetsFilterEnabled = false;
controller.account!.deleteTweetsFilterText = "@someone";

seedTweet(controller, { tweetID: "a", text: "@someone hi" });
seedTweet(controller, { tweetID: "b", text: "unrelated" });

const result = await controller.deleteTweetsStart();

expect(result.tweets.length).toBe(2);
});
});

describe("deleteRetweetsStart", () => {
Expand Down Expand Up @@ -137,6 +225,33 @@ describe("XAccountController - Deletion", () => {

expect(ids).toEqual(["old-retweet"]);
});

test("only returns retweets containing the filter text when enabled", async () => {
controller.account!.deleteRetweetsFilterEnabled = true;
controller.account!.deleteRetweetsFilterText = "@someone";

seedTweet(controller, {
tweetID: "retweet-of-them",
text: "RT @Someone: their post",
});
seedTweet(controller, {
tweetID: "retweet-mentioning-them",
text: "RT @other: cc @someone",
});
seedTweet(controller, {
tweetID: "other-retweet",
text: "RT @other: unrelated",
});
seedTweet(controller, {
tweetID: "regular-tweet",
text: "@someone not a retweet",
});

const result = await controller.deleteRetweetsStart();
const ids = result.tweets.map((tweet) => tweet.id).sort();

expect(ids).toEqual(["retweet-mentioning-them", "retweet-of-them"]);
});
});

describe("deleteLikesStart", () => {
Expand Down Expand Up @@ -277,5 +392,16 @@ describe("XAccountController - Deletion", () => {

expect(count).toBe(1);
});

test("applies the contains filter when total=false but not when total=true", async () => {
controller.account!.deleteTweetsFilterEnabled = true;
controller.account!.deleteTweetsFilterText = "butter";

seedTweet(controller, { tweetID: "match", text: "butter is great" });
seedTweet(controller, { tweetID: "no-match", text: "margarine is fine" });

expect(await controller.deleteTweetsCountNotArchived(false)).toBe(1);
expect(await controller.deleteTweetsCountNotArchived(true)).toBe(2);
});
});
});
Loading