|
1 | 1 | import { deepStrictEqual, ok, rejects, strictEqual } from "node:assert/strict"; |
2 | 2 | import test, { describe } from "node:test"; |
3 | 3 | import { backfill, type BackfillContext, MaxRequestsExceeded } from "./mod.ts"; |
4 | | -import { Announce, Collection, Create, Note } from "@fedify/vocab"; |
| 4 | +import { |
| 5 | + Announce, |
| 6 | + Collection, |
| 7 | + CollectionPage, |
| 8 | + Create, |
| 9 | + Note, |
| 10 | + OrderedCollection, |
| 11 | + OrderedCollectionPage, |
| 12 | +} from "@fedify/vocab"; |
5 | 13 |
|
6 | 14 | async function collect( |
7 | 15 | context: BackfillContext, |
@@ -1772,3 +1780,251 @@ describe("backfill", () => { |
1772 | 1780 | deepStrictEqual(iterations, [0, 1]); |
1773 | 1781 | }); |
1774 | 1782 | }); |
| 1783 | + |
| 1784 | +describe("collection pagination", () => { |
| 1785 | + const iri = (name: string) => new URL(`https://example.com/${name}`); |
| 1786 | + |
| 1787 | + for (const ordered of [false, true]) { |
| 1788 | + for (const replies of [false, true]) { |
| 1789 | + for (const embedded of [false, true]) { |
| 1790 | + test(`${ordered ? "ordered" : "unordered"} ${replies ? "replies" : "context"} with ${embedded ? "embedded" : "linked"} pages`, async () => { |
| 1791 | + const CollectionType = ordered ? OrderedCollection : Collection; |
| 1792 | + const PageType = ordered ? OrderedCollectionPage : CollectionPage; |
| 1793 | + const a = new Note({ id: iri("a") }); |
| 1794 | + const b = new Note({ id: iri("b") }); |
| 1795 | + const second = new PageType({ id: iri("page-2"), items: [b] }); |
| 1796 | + const first = new PageType({ |
| 1797 | + id: iri("page-1"), |
| 1798 | + items: [a], |
| 1799 | + next: embedded ? second : second.id, |
| 1800 | + }); |
| 1801 | + const collection = new CollectionType({ |
| 1802 | + id: iri("thread"), |
| 1803 | + first: embedded ? first : first.id, |
| 1804 | + }); |
| 1805 | + const seed = new Note({ |
| 1806 | + id: iri("seed"), |
| 1807 | + contexts: replies ? [] : [iri("thread")], |
| 1808 | + replies: replies ? collection : null, |
| 1809 | + }); |
| 1810 | + const documents = new Map<string, Collection>([ |
| 1811 | + [iri("thread").href, collection], |
| 1812 | + [iri("page-1").href, first], |
| 1813 | + [iri("page-2").href, second], |
| 1814 | + ]); |
| 1815 | + const calls: string[] = []; |
| 1816 | + const context: BackfillContext = { |
| 1817 | + documentLoader: (url) => { |
| 1818 | + calls.push(url.href); |
| 1819 | + return Promise.resolve(documents.get(url.href) ?? null); |
| 1820 | + }, |
| 1821 | + }; |
| 1822 | + const items = await collect(context, seed, { |
| 1823 | + strategies: replies ? ["reply-tree"] : ["context-auto"], |
| 1824 | + maxDepth: 1, |
| 1825 | + maxRequests: embedded ? (replies ? 0 : 1) : 3, |
| 1826 | + }); |
| 1827 | + deepStrictEqual(items.map((item) => item.id?.href), [ |
| 1828 | + a.id?.href, |
| 1829 | + b.id?.href, |
| 1830 | + ]); |
| 1831 | + deepStrictEqual( |
| 1832 | + items.map((item) => item.depth), |
| 1833 | + replies ? [1, 1] : [0, 0], |
| 1834 | + ); |
| 1835 | + strictEqual( |
| 1836 | + calls.length, |
| 1837 | + embedded ? (replies ? 0 : 1) : (replies ? 2 : 3), |
| 1838 | + ); |
| 1839 | + }); |
| 1840 | + } |
| 1841 | + } |
| 1842 | + } |
| 1843 | + |
| 1844 | + test("starting page follows next and stops before loading a repeated page", async () => { |
| 1845 | + const first = new OrderedCollectionPage({ |
| 1846 | + id: iri("page-1"), |
| 1847 | + items: [new Note({ id: iri("a") })], |
| 1848 | + next: iri("page-2"), |
| 1849 | + }); |
| 1850 | + const second = new OrderedCollectionPage({ |
| 1851 | + id: iri("page-2"), |
| 1852 | + items: [new Note({ id: iri("b") })], |
| 1853 | + next: first.id, |
| 1854 | + }); |
| 1855 | + const calls: string[] = []; |
| 1856 | + const items = await collect({ |
| 1857 | + documentLoader: (url) => { |
| 1858 | + calls.push(url.href); |
| 1859 | + return Promise.resolve(url.href === first.id?.href ? first : second); |
| 1860 | + }, |
| 1861 | + }, new Note({ contexts: [first.id!] })); |
| 1862 | + deepStrictEqual(items.map((item) => item.id?.href), [ |
| 1863 | + iri("a").href, |
| 1864 | + iri("b").href, |
| 1865 | + ]); |
| 1866 | + deepStrictEqual(calls, [first.id?.href, second.id?.href]); |
| 1867 | + }); |
| 1868 | + |
| 1869 | + for (const limit of ["maxItems", "maxRequests"] as const) { |
| 1870 | + test(`${limit} stops between pages`, async () => { |
| 1871 | + const first = new CollectionPage({ |
| 1872 | + id: iri("page-1"), |
| 1873 | + items: [new Note({ id: iri("a") })], |
| 1874 | + next: iri("page-2"), |
| 1875 | + }); |
| 1876 | + const calls: string[] = []; |
| 1877 | + const items = await collect( |
| 1878 | + { |
| 1879 | + documentLoader: (url) => { |
| 1880 | + calls.push(url.href); |
| 1881 | + return Promise.resolve(first); |
| 1882 | + }, |
| 1883 | + }, |
| 1884 | + new Note({ |
| 1885 | + contexts: [first.id!], |
| 1886 | + replies: new Collection({ |
| 1887 | + items: [new Note({ id: iri("fallback") })], |
| 1888 | + }), |
| 1889 | + }), |
| 1890 | + { [limit]: 1, strategies: ["context-auto", "reply-tree"] }, |
| 1891 | + ); |
| 1892 | + deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]); |
| 1893 | + deepStrictEqual(calls, [first.id?.href]); |
| 1894 | + }); |
| 1895 | + } |
| 1896 | + |
| 1897 | + for (const failure of ["missing", "throw", "invalid"] as const) { |
| 1898 | + test(`${failure} page retains items and allows the next strategy`, async () => { |
| 1899 | + const seed = new Note({ |
| 1900 | + contexts: [iri("thread")], |
| 1901 | + replies: new Collection({ items: [new Note({ id: iri("reply") })] }), |
| 1902 | + }); |
| 1903 | + const first = new CollectionPage({ |
| 1904 | + items: [new Note({ id: iri("a") })], |
| 1905 | + next: iri("broken"), |
| 1906 | + }); |
| 1907 | + const items = await collect( |
| 1908 | + { |
| 1909 | + documentLoader: (url) => { |
| 1910 | + if (url.href === iri("thread").href) { |
| 1911 | + return Promise.resolve(new Collection({ first })); |
| 1912 | + } |
| 1913 | + if (failure === "throw") throw new Error("page failed"); |
| 1914 | + return Promise.resolve(failure === "missing" ? null : new Note({})); |
| 1915 | + }, |
| 1916 | + }, |
| 1917 | + seed, |
| 1918 | + { strategies: ["context-auto", "reply-tree"] }, |
| 1919 | + ); |
| 1920 | + deepStrictEqual(items.map((item) => item.id?.href), [ |
| 1921 | + iri("a").href, |
| 1922 | + iri("reply").href, |
| 1923 | + ]); |
| 1924 | + }); |
| 1925 | + } |
| 1926 | + |
| 1927 | + test("inline items precede page items", async () => { |
| 1928 | + const items = await collect({ |
| 1929 | + documentLoader: () => |
| 1930 | + Promise.resolve( |
| 1931 | + new Collection({ |
| 1932 | + items: [new Note({ id: iri("inline") })], |
| 1933 | + first: new CollectionPage({ |
| 1934 | + items: [new Note({ id: iri("paged") })], |
| 1935 | + }), |
| 1936 | + }), |
| 1937 | + ), |
| 1938 | + }, new Note({ contexts: [iri("thread")] })); |
| 1939 | + deepStrictEqual(items.map((item) => item.id?.href), [ |
| 1940 | + iri("inline").href, |
| 1941 | + iri("paged").href, |
| 1942 | + ]); |
| 1943 | + }); |
| 1944 | + |
| 1945 | + test("cancellation during a page load prevents later strategies", async () => { |
| 1946 | + const controller = new AbortController(); |
| 1947 | + const reason = new Error("cancelled"); |
| 1948 | + const seed = new Note({ |
| 1949 | + contexts: [iri("thread")], |
| 1950 | + replies: new Collection({ items: [new Note({ id: iri("fallback") })] }), |
| 1951 | + }); |
| 1952 | + await rejects( |
| 1953 | + collect( |
| 1954 | + { |
| 1955 | + documentLoader: (url, options) => { |
| 1956 | + strictEqual(options?.signal, controller.signal); |
| 1957 | + if (url.href === iri("thread").href) { |
| 1958 | + return Promise.resolve(new Collection({ first: iri("page") })); |
| 1959 | + } |
| 1960 | + controller.abort(reason); |
| 1961 | + throw reason; |
| 1962 | + }, |
| 1963 | + }, |
| 1964 | + seed, |
| 1965 | + { |
| 1966 | + signal: controller.signal, |
| 1967 | + strategies: ["context-auto", "reply-tree"], |
| 1968 | + }, |
| 1969 | + ), |
| 1970 | + (error) => error === reason, |
| 1971 | + ); |
| 1972 | + }); |
| 1973 | +}); |
| 1974 | + |
| 1975 | +test("page loads share the cache and interval across strategies", async () => { |
| 1976 | + const iri = (name: string) => new URL(`https://example.com/${name}`); |
| 1977 | + const collection = new Collection({ first: iri("page") }); |
| 1978 | + const page = new CollectionPage({ items: [new Note({ id: iri("a") })] }); |
| 1979 | + const seed = new Note({ |
| 1980 | + contexts: [iri("thread")], |
| 1981 | + replies: new Collection({ first: iri("page") }), |
| 1982 | + }); |
| 1983 | + const calls: string[] = []; |
| 1984 | + const intervals: number[] = []; |
| 1985 | + const items = await collect( |
| 1986 | + { |
| 1987 | + documentLoader: (url) => { |
| 1988 | + calls.push(url.href); |
| 1989 | + return Promise.resolve( |
| 1990 | + url.href === iri("thread").href ? collection : page, |
| 1991 | + ); |
| 1992 | + }, |
| 1993 | + }, |
| 1994 | + seed, |
| 1995 | + { |
| 1996 | + strategies: ["context-auto", "reply-tree"], |
| 1997 | + maxRequests: 2, |
| 1998 | + interval: (count) => { |
| 1999 | + intervals.push(count); |
| 2000 | + return { milliseconds: 0 }; |
| 2001 | + }, |
| 2002 | + }, |
| 2003 | + ); |
| 2004 | + deepStrictEqual(items.map((item) => item.id?.href), [iri("a").href]); |
| 2005 | + deepStrictEqual(calls, [iri("thread").href, iri("page").href]); |
| 2006 | + deepStrictEqual(intervals, [0, 1]); |
| 2007 | +}); |
| 2008 | + |
| 2009 | +test("embedded pages without IDs follow next without loader calls", async () => { |
| 2010 | + const a = new Note({ id: new URL("https://example.com/a") }); |
| 2011 | + const b = new Note({ id: new URL("https://example.com/b") }); |
| 2012 | + const seed = new Note({ |
| 2013 | + replies: new Collection({ |
| 2014 | + first: new CollectionPage({ |
| 2015 | + items: [a], |
| 2016 | + next: new CollectionPage({ items: [b] }), |
| 2017 | + }), |
| 2018 | + }), |
| 2019 | + }); |
| 2020 | + const items = await collect( |
| 2021 | + { |
| 2022 | + documentLoader: () => { |
| 2023 | + throw new Error("embedded pages must not be fetched"); |
| 2024 | + }, |
| 2025 | + }, |
| 2026 | + seed, |
| 2027 | + { strategies: ["reply-tree"], maxRequests: 0, maxDepth: 1 }, |
| 2028 | + ); |
| 2029 | + deepStrictEqual(items.map((item) => item.id?.href), [a.id?.href, b.id?.href]); |
| 2030 | +}); |
0 commit comments