[READ-ONLY] Mirror of https://github.com/improsocial/impro An extensible Bluesky client for web impro.social
6

Configure Feed

Select the types of activity you want to include in your feed.

Add more datalayer unit tests

Grace Kind (May 15, 2026, 11:57 PM -0500) ec48cc2b fe0f7ad5

+4397 -2
+499 -1
tests/unit/specs/api.test.js
··· 1 1 import { TestSuite } from "../testSuite.js"; 2 - import { assert, assertEquals } from "../testHelpers.js"; 2 + import { assert, assertEquals, MockFetch } from "../testHelpers.js"; 3 3 import { Api, ApiError } from "/js/api.js"; 4 4 5 5 const t = new TestSuite("Api"); ··· 157 157 158 158 assert(thrownError instanceof ApiError); 159 159 assertEquals(thrownError.status, 400); 160 + }); 161 + 162 + it("should expose error data and status text on ApiError for non-200", async () => { 163 + const session = { 164 + serviceEndpoint: "https://test.example.com", 165 + did: "did:plc:testuser", 166 + fetch: async () => ({ 167 + ok: false, 168 + status: 500, 169 + statusText: "Internal Server Error", 170 + json: async () => ({ error: "InternalServerError", message: "boom" }), 171 + }), 172 + }; 173 + const api = new Api(session); 174 + 175 + let thrownError = null; 176 + try { 177 + await api.request("com.example.method"); 178 + } catch (e) { 179 + thrownError = e; 180 + } 181 + 182 + assert(thrownError instanceof ApiError); 183 + assertEquals(thrownError.status, 500); 184 + assertEquals(thrownError.statusText, "Internal Server Error"); 185 + assertEquals(thrownError.data.error, "InternalServerError"); 186 + }); 187 + 188 + it("should re-throw non-refresh errors raised during fetch", async () => { 189 + const session = { 190 + serviceEndpoint: "https://test.example.com", 191 + did: "did:plc:testuser", 192 + fetch: async () => { 193 + throw new Error("network down"); 194 + }, 195 + }; 196 + const api = new Api(session); 197 + 198 + let thrownError = null; 199 + try { 200 + await api.request("com.example.method"); 201 + } catch (e) { 202 + thrownError = e; 203 + } 204 + 205 + assert(thrownError !== null); 206 + assertEquals(thrownError.message, "network down"); 207 + }); 208 + 209 + it("should inject atproto-proxy header on AppView-routed requests", async () => { 210 + const session = createMockSession({ 211 + did: "did:plc:test", 212 + handle: "test.user", 213 + }); 214 + const api = new Api(session); 215 + 216 + await api.getProfile("did:plc:test"); 217 + 218 + const { options } = session.getLastFetchOptions(); 219 + assertEquals( 220 + options.headers["atproto-proxy"], 221 + "did:web:api.bsky.app#bsky_appview", 222 + ); 223 + }); 224 + 225 + it("should use response data set by oauth library without re-reading", async () => { 226 + const cachedData = { result: "from-oauth-lib" }; 227 + const session = { 228 + serviceEndpoint: "https://test.example.com", 229 + did: "did:plc:testuser", 230 + fetch: async () => ({ 231 + ok: true, 232 + data: cachedData, 233 + json: async () => { 234 + throw new Error("should not be called"); 235 + }, 236 + }), 237 + }; 238 + const api = new Api(session); 239 + 240 + const res = await api.request("com.example.method"); 241 + 242 + assertEquals(res.data, cachedData); 160 243 }); 161 244 }); 162 245 ··· 1338 1421 const { options } = session.getLastFetchOptions(); 1339 1422 const body = JSON.parse(options.body); 1340 1423 assert(body.reason === undefined); 1424 + }); 1425 + }); 1426 + 1427 + t.describe("getBlocks", (it) => { 1428 + it("should fetch blocked accounts", async () => { 1429 + const session = createMockSession({ 1430 + blocks: [{ did: "did:plc:a" }], 1431 + cursor: "next", 1432 + }); 1433 + const api = new Api(session); 1434 + 1435 + const result = await api.getBlocks(); 1436 + 1437 + const { url } = session.getLastFetchOptions(); 1438 + assert(url.includes("app.bsky.graph.getBlocks")); 1439 + assert(url.includes("limit=50")); 1440 + assertEquals(result.blocks.length, 1); 1441 + assertEquals(result.cursor, "next"); 1442 + }); 1443 + 1444 + it("should pass cursor when provided", async () => { 1445 + const session = createMockSession({ blocks: [], cursor: "" }); 1446 + const api = new Api(session); 1447 + 1448 + await api.getBlocks({ cursor: "abc" }); 1449 + 1450 + const { url } = session.getLastFetchOptions(); 1451 + assert(url.includes("cursor=abc")); 1452 + }); 1453 + }); 1454 + 1455 + t.describe("searchFeedGenerators", (it) => { 1456 + it("should search popular feed generators", async () => { 1457 + const session = createMockSession({ 1458 + feeds: [{ uri: "feed1" }], 1459 + cursor: "next", 1460 + }); 1461 + const api = new Api(session); 1462 + 1463 + const result = await api.searchFeedGenerators("news"); 1464 + 1465 + const { url, options } = session.getLastFetchOptions(); 1466 + assert(url.includes("app.bsky.unspecced.getPopularFeedGenerators")); 1467 + assert(url.includes("query=news")); 1468 + assert(url.includes("limit=15")); 1469 + assertEquals( 1470 + options.headers["atproto-proxy"], 1471 + "did:web:api.bsky.app#bsky_appview", 1472 + ); 1473 + assertEquals(result.feeds.length, 1); 1474 + }); 1475 + 1476 + it("should pass cursor when provided", async () => { 1477 + const session = createMockSession({ feeds: [] }); 1478 + const api = new Api(session); 1479 + 1480 + await api.searchFeedGenerators("news", { cursor: "abc" }); 1481 + 1482 + const { url } = session.getLastFetchOptions(); 1483 + assert(url.includes("cursor=abc")); 1484 + }); 1485 + }); 1486 + 1487 + t.describe("getActorFeeds", (it) => { 1488 + it("should fetch feeds created by an actor", async () => { 1489 + const session = createMockSession({ 1490 + feeds: [{ uri: "feed1" }], 1491 + cursor: "next", 1492 + }); 1493 + const api = new Api(session); 1494 + 1495 + const result = await api.getActorFeeds("did:plc:user"); 1496 + 1497 + const { url } = session.getLastFetchOptions(); 1498 + assert(url.includes("app.bsky.feed.getActorFeeds")); 1499 + assert(url.includes("actor=did%3Aplc%3Auser")); 1500 + assert(url.includes("limit=50")); 1501 + assertEquals(result.feeds.length, 1); 1502 + }); 1503 + 1504 + it("should pass cursor when provided", async () => { 1505 + const session = createMockSession({ feeds: [] }); 1506 + const api = new Api(session); 1507 + 1508 + await api.getActorFeeds("did:plc:user", { cursor: "abc" }); 1509 + 1510 + const { url } = session.getLastFetchOptions(); 1511 + assert(url.includes("cursor=abc")); 1512 + }); 1513 + }); 1514 + 1515 + t.describe("getReposts", (it) => { 1516 + it("should fetch repost records and skip failures", async () => { 1517 + let callCount = 0; 1518 + const session = { 1519 + serviceEndpoint: "https://test.example.com", 1520 + did: "did:plc:testuser", 1521 + fetch: async (url) => { 1522 + callCount += 1; 1523 + if (callCount === 2) { 1524 + return { 1525 + ok: false, 1526 + status: 404, 1527 + statusText: "Not Found", 1528 + json: async () => ({ error: "RecordNotFound" }), 1529 + }; 1530 + } 1531 + return { 1532 + ok: true, 1533 + json: async () => ({ 1534 + uri: url, 1535 + value: { subject: { uri: "post1", cid: "cid1" } }, 1536 + }), 1537 + }; 1538 + }, 1539 + }; 1540 + const api = new Api(session); 1541 + 1542 + const reposts = await api.getReposts([ 1543 + "at://did:plc:a/app.bsky.feed.repost/1", 1544 + "at://did:plc:b/app.bsky.feed.repost/2", 1545 + "at://did:plc:c/app.bsky.feed.repost/3", 1546 + ]); 1547 + 1548 + assertEquals(reposts.length, 2); 1549 + }); 1550 + }); 1551 + 1552 + t.describe("putProfileRecord", (it) => { 1553 + it("should put profile record with $type and null swapRecord by default", async () => { 1554 + const session = createMockSession({ uri: "rec", cid: "cid" }); 1555 + const api = new Api(session); 1556 + 1557 + await api.putProfileRecord({ displayName: "Alice" }); 1558 + 1559 + const { url, options } = session.getLastFetchOptions(); 1560 + assert(url.includes("com.atproto.repo.putRecord")); 1561 + assertEquals(options.method, "POST"); 1562 + const body = JSON.parse(options.body); 1563 + assertEquals(body.repo, "did:plc:testuser"); 1564 + assertEquals(body.collection, "app.bsky.actor.profile"); 1565 + assertEquals(body.rkey, "self"); 1566 + assertEquals(body.record.$type, "app.bsky.actor.profile"); 1567 + assertEquals(body.record.displayName, "Alice"); 1568 + assertEquals(body.swapRecord, null); 1569 + }); 1570 + 1571 + it("should include swapRecord cid for conditional write", async () => { 1572 + const session = createMockSession({ uri: "rec", cid: "cid" }); 1573 + const api = new Api(session); 1574 + 1575 + await api.putProfileRecord({ displayName: "Bob" }, "previouscid"); 1576 + 1577 + const { options } = session.getLastFetchOptions(); 1578 + const body = JSON.parse(options.body); 1579 + assertEquals(body.swapRecord, "previouscid"); 1580 + }); 1581 + }); 1582 + 1583 + t.describe("putActivitySubscription", (it) => { 1584 + it("should put activity subscription with subject and subscription", async () => { 1585 + const session = createMockSession({ subject: "did:plc:target" }); 1586 + const api = new Api(session); 1587 + const activitySubscription = { post: true, reply: false }; 1588 + 1589 + await api.putActivitySubscription("did:plc:target", activitySubscription); 1590 + 1591 + const { url, options } = session.getLastFetchOptions(); 1592 + assert(url.includes("app.bsky.notification.putActivitySubscription")); 1593 + assertEquals(options.method, "POST"); 1594 + assertEquals( 1595 + options.headers["atproto-proxy"], 1596 + "did:web:api.bsky.app#bsky_appview", 1597 + ); 1598 + const body = JSON.parse(options.body); 1599 + assertEquals(body.subject, "did:plc:target"); 1600 + assertEquals(body.activitySubscription, activitySubscription); 1601 + }); 1602 + }); 1603 + 1604 + t.describe("getServiceAuthToken", (it) => { 1605 + it("should fetch service auth token with aud and lxm", async () => { 1606 + const session = createMockSession({ token: "service-token-123" }); 1607 + const api = new Api(session); 1608 + 1609 + const token = await api.getServiceAuthToken({ 1610 + aud: "did:web:video.bsky.app", 1611 + lxm: "app.bsky.video.getUploadLimits", 1612 + }); 1613 + 1614 + const { url } = session.getLastFetchOptions(); 1615 + assert(url.includes("com.atproto.server.getServiceAuth")); 1616 + assert(url.includes("aud=did%3Aweb%3Avideo.bsky.app")); 1617 + assert(url.includes("lxm=app.bsky.video.getUploadLimits")); 1618 + assert(url.includes("exp=")); 1619 + assertEquals(token, "service-token-123"); 1620 + }); 1621 + 1622 + it("should use provided exp value", async () => { 1623 + const session = createMockSession({ token: "service-token-123" }); 1624 + const api = new Api(session); 1625 + 1626 + await api.getServiceAuthToken({ 1627 + aud: "did:web:video.bsky.app", 1628 + lxm: "com.atproto.repo.uploadBlob", 1629 + exp: 1234567890, 1630 + }); 1631 + 1632 + const { url } = session.getLastFetchOptions(); 1633 + assert(url.includes("exp=1234567890")); 1634 + }); 1635 + }); 1636 + 1637 + t.describe("serviceRequest", (it, hooks) => { 1638 + let originalFetch = null; 1639 + hooks.beforeEach(() => { 1640 + originalFetch = globalThis.fetch; 1641 + }); 1642 + hooks.afterEach(() => { 1643 + globalThis.fetch = originalFetch; 1644 + }); 1645 + 1646 + it("should send request with bearer token to external service", async () => { 1647 + const mockFetch = new MockFetch(); 1648 + mockFetch.__interceptJson("https://video.example.com", { ok: true }); 1649 + globalThis.fetch = mockFetch; 1650 + const api = new Api(createMockSession({})); 1651 + 1652 + await api.serviceRequest("https://video.example.com/xrpc/some.method", { 1653 + token: "abc-token", 1654 + query: { foo: "bar" }, 1655 + }); 1656 + 1657 + const lastCall = mockFetch.calls[0]; 1658 + assertEquals( 1659 + lastCall.url, 1660 + "https://video.example.com/xrpc/some.method?foo=bar", 1661 + ); 1662 + assertEquals(lastCall.options.headers.Authorization, "Bearer abc-token"); 1663 + assertEquals(lastCall.options.method, "GET"); 1664 + }); 1665 + 1666 + it("should omit Authorization when no token is provided", async () => { 1667 + const mockFetch = new MockFetch(); 1668 + mockFetch.__interceptJson("https://video.example.com", { ok: true }); 1669 + globalThis.fetch = mockFetch; 1670 + const api = new Api(createMockSession({})); 1671 + 1672 + await api.serviceRequest("https://video.example.com/xrpc/some.method"); 1673 + 1674 + const lastCall = mockFetch.calls[0]; 1675 + assert(lastCall.options.headers.Authorization === undefined); 1676 + }); 1677 + 1678 + it("should throw ApiError when response is not ok", async () => { 1679 + const mockFetch = new MockFetch(); 1680 + mockFetch.__intercept("https://video.example.com", async () => ({ 1681 + ok: false, 1682 + status: 400, 1683 + statusText: "Bad Request", 1684 + json: async () => ({ error: "BadRequest" }), 1685 + })); 1686 + globalThis.fetch = mockFetch; 1687 + const api = new Api(createMockSession({})); 1688 + 1689 + let thrownError = null; 1690 + try { 1691 + await api.serviceRequest("https://video.example.com/xrpc/some.method", { 1692 + token: "abc", 1693 + }); 1694 + } catch (e) { 1695 + thrownError = e; 1696 + } 1697 + 1698 + assert(thrownError instanceof ApiError); 1699 + assertEquals(thrownError.status, 400); 1700 + }); 1701 + }); 1702 + 1703 + t.describe("getVideoUploadLimits", (it, hooks) => { 1704 + let originalFetch = null; 1705 + hooks.beforeEach(() => { 1706 + originalFetch = globalThis.fetch; 1707 + }); 1708 + hooks.afterEach(() => { 1709 + globalThis.fetch = originalFetch; 1710 + }); 1711 + 1712 + it("should fetch upload limits using a service auth token", async () => { 1713 + const session = createMockSession({ token: "video-token" }); 1714 + const mockFetch = new MockFetch(); 1715 + mockFetch.__interceptJson("https://video.bsky.app", { 1716 + canUpload: true, 1717 + remainingDailyVideos: 5, 1718 + }); 1719 + globalThis.fetch = mockFetch; 1720 + const api = new Api(session); 1721 + 1722 + const result = await api.getVideoUploadLimits(); 1723 + 1724 + const videoCall = mockFetch.calls[0]; 1725 + assert(videoCall.url.includes("app.bsky.video.getUploadLimits")); 1726 + assertEquals(videoCall.options.headers.Authorization, "Bearer video-token"); 1727 + assertEquals(result.canUpload, true); 1728 + assertEquals(result.remainingDailyVideos, 5); 1729 + }); 1730 + }); 1731 + 1732 + t.describe("getVideoJobStatus", (it, hooks) => { 1733 + let originalFetch = null; 1734 + hooks.beforeEach(() => { 1735 + originalFetch = globalThis.fetch; 1736 + }); 1737 + hooks.afterEach(() => { 1738 + globalThis.fetch = originalFetch; 1739 + }); 1740 + 1741 + it("should fetch job status by jobId", async () => { 1742 + const mockFetch = new MockFetch(); 1743 + mockFetch.__interceptJson("https://video.bsky.app", { 1744 + jobStatus: { jobId: "job1", state: "JOB_STATE_COMPLETED" }, 1745 + }); 1746 + globalThis.fetch = mockFetch; 1747 + const api = new Api(createMockSession({})); 1748 + 1749 + const result = await api.getVideoJobStatus("job1"); 1750 + 1751 + const videoCall = mockFetch.calls[0]; 1752 + assert(videoCall.url.includes("app.bsky.video.getJobStatus")); 1753 + assert(videoCall.url.includes("jobId=job1")); 1754 + assertEquals(result.jobId, "job1"); 1755 + assertEquals(result.state, "JOB_STATE_COMPLETED"); 1756 + }); 1757 + }); 1758 + 1759 + t.describe("uploadVideoBlob", (it, hooks) => { 1760 + let originalFetch = null; 1761 + hooks.beforeEach(() => { 1762 + originalFetch = globalThis.fetch; 1763 + }); 1764 + hooks.afterEach(() => { 1765 + globalThis.fetch = originalFetch; 1766 + }); 1767 + 1768 + it("should upload video using service auth token and return job data", async () => { 1769 + const session = createMockSession({ token: "upload-token" }); 1770 + const mockFetch = new MockFetch(); 1771 + mockFetch.__interceptJson("https://video.bsky.app", { 1772 + jobId: "job1", 1773 + state: "JOB_STATE_CREATED", 1774 + }); 1775 + globalThis.fetch = mockFetch; 1776 + const api = new Api(session); 1777 + const file = new Blob(["video-bytes"], { type: "video/mp4" }); 1778 + file.name = "clip.mp4"; 1779 + 1780 + const result = await api.uploadVideoBlob(file); 1781 + 1782 + const videoCall = mockFetch.calls[0]; 1783 + assert(videoCall.url.includes("app.bsky.video.uploadVideo")); 1784 + assert(videoCall.url.includes("did=did%3Aplc%3Atestuser")); 1785 + assert(videoCall.url.includes("name=clip.mp4")); 1786 + assertEquals(videoCall.options.method, "POST"); 1787 + assertEquals( 1788 + videoCall.options.headers.Authorization, 1789 + "Bearer upload-token", 1790 + ); 1791 + assertEquals(videoCall.options.headers["Content-Type"], "video/mp4"); 1792 + assertEquals(videoCall.options.body, file); 1793 + assertEquals(result.jobId, "job1"); 1794 + }); 1795 + 1796 + it("should treat already_exists 409 as success and return existing job", async () => { 1797 + const session = createMockSession({ token: "upload-token" }); 1798 + const mockFetch = new MockFetch(); 1799 + mockFetch.__intercept("https://video.bsky.app", async () => ({ 1800 + ok: false, 1801 + status: 409, 1802 + statusText: "Conflict", 1803 + json: async () => ({ error: "already_exists", jobId: "existing-job" }), 1804 + })); 1805 + globalThis.fetch = mockFetch; 1806 + const api = new Api(session); 1807 + const file = new Blob(["video"], { type: "video/mp4" }); 1808 + file.name = "clip.mp4"; 1809 + 1810 + const result = await api.uploadVideoBlob(file); 1811 + 1812 + assertEquals(result.error, "already_exists"); 1813 + assertEquals(result.jobId, "existing-job"); 1814 + }); 1815 + 1816 + it("should re-throw non-409 errors", async () => { 1817 + const session = createMockSession({ token: "upload-token" }); 1818 + const mockFetch = new MockFetch(); 1819 + mockFetch.__intercept("https://video.bsky.app", async () => ({ 1820 + ok: false, 1821 + status: 500, 1822 + statusText: "Server Error", 1823 + json: async () => ({ error: "InternalError" }), 1824 + })); 1825 + globalThis.fetch = mockFetch; 1826 + const api = new Api(session); 1827 + const file = new Blob(["video"], { type: "video/mp4" }); 1828 + file.name = "clip.mp4"; 1829 + 1830 + let thrownError = null; 1831 + try { 1832 + await api.uploadVideoBlob(file); 1833 + } catch (e) { 1834 + thrownError = e; 1835 + } 1836 + 1837 + assert(thrownError instanceof ApiError); 1838 + assertEquals(thrownError.status, 500); 1341 1839 }); 1342 1840 }); 1343 1841
+383
tests/unit/specs/dataLayer/dataStore.test.js
··· 410 410 }); 411 411 }); 412 412 413 + t.describe("Blocked Profiles Management", (it) => { 414 + const blockedList = { 415 + blocks: [{ did: "did:plc:b", handle: "b.bsky.social" }], 416 + cursor: "next", 417 + }; 418 + 419 + it("should return null before any blocked profiles are set", () => { 420 + const dataStore = new DataStore(); 421 + assertEquals(dataStore.hasBlockedProfiles(), false); 422 + assertEquals(dataStore.getBlockedProfiles(), null); 423 + }); 424 + 425 + it("should set and get blocked profiles", () => { 426 + const dataStore = new DataStore(); 427 + dataStore.setBlockedProfiles(blockedList); 428 + assertEquals(dataStore.hasBlockedProfiles(), true); 429 + assertEquals(dataStore.getBlockedProfiles(), blockedList); 430 + }); 431 + 432 + it("should clear blocked profiles", () => { 433 + const dataStore = new DataStore(); 434 + dataStore.setBlockedProfiles(blockedList); 435 + dataStore.clearBlockedProfiles(); 436 + assertEquals(dataStore.hasBlockedProfiles(), false); 437 + assertEquals(dataStore.getBlockedProfiles(), null); 438 + }); 439 + }); 440 + 441 + t.describe("Notifications Management", (it) => { 442 + const notifications = [{ uri: "at://did:test/notif/1", reason: "like" }]; 443 + 444 + it("should return null before notifications are set", () => { 445 + const dataStore = new DataStore(); 446 + assertEquals(dataStore.hasNotifications(), false); 447 + assertEquals(dataStore.getNotifications(), null); 448 + }); 449 + 450 + it("should set and get notifications", () => { 451 + const dataStore = new DataStore(); 452 + dataStore.setNotifications(notifications); 453 + assertEquals(dataStore.hasNotifications(), true); 454 + assertEquals(dataStore.getNotifications(), notifications); 455 + }); 456 + 457 + it("should clear notifications without clearing the cursor", () => { 458 + const dataStore = new DataStore(); 459 + dataStore.setNotifications(notifications); 460 + dataStore.setNotificationCursor("cursor-1"); 461 + dataStore.clearNotifications(); 462 + assertEquals(dataStore.hasNotifications(), false); 463 + assertEquals(dataStore.getNotifications(), null); 464 + assertEquals(dataStore.hasNotificationCursor(), true); 465 + assertEquals(dataStore.getNotificationCursor(), "cursor-1"); 466 + }); 467 + 468 + it("should set and clear the notification cursor independently", () => { 469 + const dataStore = new DataStore(); 470 + assertEquals(dataStore.hasNotificationCursor(), false); 471 + dataStore.setNotificationCursor("cursor-2"); 472 + assertEquals(dataStore.hasNotificationCursor(), true); 473 + assertEquals(dataStore.getNotificationCursor(), "cursor-2"); 474 + dataStore.clearNotificationCursor(); 475 + assertEquals(dataStore.hasNotificationCursor(), false); 476 + assertEquals(dataStore.getNotificationCursor(), null); 477 + }); 478 + 479 + it("should emit setNotifications event", () => { 480 + const dataStore = new DataStore(); 481 + let emitted = null; 482 + dataStore.on("setNotifications", (value) => { 483 + emitted = value; 484 + }); 485 + dataStore.setNotifications(notifications); 486 + assertEquals(emitted, notifications); 487 + }); 488 + }); 489 + 490 + t.describe("Mention Notifications Management", (it) => { 491 + const mentions = [{ uri: "at://did:test/notif/mention", reason: "mention" }]; 492 + 493 + it("should default to undefined before being set", () => { 494 + const dataStore = new DataStore(); 495 + assertEquals(dataStore.getMentionNotifications(), null); 496 + assertEquals(dataStore.getMentionNotificationCursor(), null); 497 + }); 498 + 499 + it("should set and get mention notifications", () => { 500 + const dataStore = new DataStore(); 501 + dataStore.setMentionNotifications(mentions); 502 + assertEquals(dataStore.getMentionNotifications(), mentions); 503 + }); 504 + 505 + it("should clear mention notifications without clearing cursor", () => { 506 + const dataStore = new DataStore(); 507 + dataStore.setMentionNotifications(mentions); 508 + dataStore.setMentionNotificationCursor("m-cursor"); 509 + dataStore.clearMentionNotifications(); 510 + assertEquals(dataStore.getMentionNotifications(), null); 511 + assertEquals(dataStore.getMentionNotificationCursor(), "m-cursor"); 512 + }); 513 + 514 + it("should set the mention notification cursor", () => { 515 + const dataStore = new DataStore(); 516 + dataStore.setMentionNotificationCursor("m-cursor-2"); 517 + assertEquals(dataStore.getMentionNotificationCursor(), "m-cursor-2"); 518 + }); 519 + }); 520 + 521 + t.describe("ConvoList Management", (it) => { 522 + const convos = [{ id: "convo-1" }, { id: "convo-2" }]; 523 + 524 + it("should return null before convo list is set", () => { 525 + const dataStore = new DataStore(); 526 + assertEquals(dataStore.hasConvoList(), false); 527 + assertEquals(dataStore.getConvoList(), null); 528 + }); 529 + 530 + it("should set and get the convo list", () => { 531 + const dataStore = new DataStore(); 532 + dataStore.setConvoList(convos); 533 + assertEquals(dataStore.hasConvoList(), true); 534 + assertEquals(dataStore.getConvoList(), convos); 535 + }); 536 + 537 + it("should clear the convo list without clearing the cursor", () => { 538 + const dataStore = new DataStore(); 539 + dataStore.setConvoList(convos); 540 + dataStore.setConvoListCursor("c-cursor"); 541 + dataStore.clearConvoList(); 542 + assertEquals(dataStore.hasConvoList(), false); 543 + assertEquals(dataStore.getConvoList(), null); 544 + assertEquals(dataStore.hasConvoListCursor(), true); 545 + assertEquals(dataStore.getConvoListCursor(), "c-cursor"); 546 + }); 547 + 548 + it("should set and clear the convo list cursor independently", () => { 549 + const dataStore = new DataStore(); 550 + assertEquals(dataStore.hasConvoListCursor(), false); 551 + dataStore.setConvoListCursor("c-cursor-2"); 552 + assertEquals(dataStore.hasConvoListCursor(), true); 553 + assertEquals(dataStore.getConvoListCursor(), "c-cursor-2"); 554 + dataStore.clearConvoListCursor(); 555 + assertEquals(dataStore.hasConvoListCursor(), false); 556 + assertEquals(dataStore.getConvoListCursor(), null); 557 + }); 558 + }); 559 + 560 + t.describe("Convo Management", (it) => { 561 + const convoId = "convo-123"; 562 + const convo = { id: convoId, members: [{ did: "did:plc:a" }] }; 563 + 564 + it("should set, get, and check existence of a convo", () => { 565 + const dataStore = new DataStore(); 566 + assertEquals(dataStore.hasConvo(convoId), false); 567 + dataStore.setConvo(convoId, convo); 568 + assertEquals(dataStore.hasConvo(convoId), true); 569 + assertEquals(dataStore.getConvo(convoId), convo); 570 + }); 571 + 572 + it("should clear a convo", () => { 573 + const dataStore = new DataStore(); 574 + dataStore.setConvo(convoId, convo); 575 + dataStore.clearConvo(convoId); 576 + assertEquals(dataStore.hasConvo(convoId), false); 577 + assertEquals(dataStore.getConvo(convoId), undefined); 578 + }); 579 + 580 + it("should return all convos", () => { 581 + const dataStore = new DataStore(); 582 + const convoA = { id: "a" }; 583 + const convoB = { id: "b" }; 584 + dataStore.setConvo("a", convoA); 585 + dataStore.setConvo("b", convoB); 586 + assertEquals(dataStore.getAllConvos(), [convoA, convoB]); 587 + }); 588 + }); 589 + 590 + t.describe("ConvoMessages and Message Mapping", (it) => { 591 + const convoId = "convo-xyz"; 592 + const messageA = { id: "msg-1", text: "hello" }; 593 + const messageB = { id: "msg-2", text: "world" }; 594 + const payload = { messages: [messageA, messageB], cursor: "next" }; 595 + 596 + it("should set and get convo messages", () => { 597 + const dataStore = new DataStore(); 598 + assertEquals(dataStore.hasConvoMessages(convoId), false); 599 + assertEquals(dataStore.getConvoMessages(convoId), null); 600 + dataStore.setConvoMessages(convoId, payload); 601 + assertEquals(dataStore.hasConvoMessages(convoId), true); 602 + assertEquals(dataStore.getConvoMessages(convoId), payload); 603 + }); 604 + 605 + it("should clear convo messages", () => { 606 + const dataStore = new DataStore(); 607 + dataStore.setConvoMessages(convoId, payload); 608 + dataStore.clearConvoMessages(convoId); 609 + assertEquals(dataStore.hasConvoMessages(convoId), false); 610 + assertEquals(dataStore.getConvoMessages(convoId), null); 611 + }); 612 + 613 + it("should set, get, and clear individual messages by id", () => { 614 + const dataStore = new DataStore(); 615 + assertEquals(dataStore.hasMessage(messageA.id), false); 616 + dataStore.setMessage(messageA.id, messageA); 617 + assertEquals(dataStore.hasMessage(messageA.id), true); 618 + assertEquals(dataStore.getMessage(messageA.id), messageA); 619 + dataStore.clearMessage(messageA.id); 620 + assertEquals(dataStore.hasMessage(messageA.id), false); 621 + assertEquals(dataStore.getMessage(messageA.id), undefined); 622 + }); 623 + 624 + it("should keep message ids isolated from convo messages buckets", () => { 625 + const dataStore = new DataStore(); 626 + dataStore.setConvoMessages(convoId, payload); 627 + assertEquals(dataStore.hasMessage(messageA.id), false); 628 + dataStore.setMessage(messageA.id, messageA); 629 + assertEquals(dataStore.hasMessage(messageA.id), true); 630 + assertEquals(dataStore.hasConvoMessages(convoId), true); 631 + }); 632 + }); 633 + 634 + t.describe("ShowLess and ShowMore Interactions", (it) => { 635 + const interactionA = { item: "post-a", event: "less" }; 636 + const interactionB = { item: "post-b", event: "less" }; 637 + const interactionC = { item: "post-c", event: "more" }; 638 + 639 + it("should start with empty interaction lists", () => { 640 + const dataStore = new DataStore(); 641 + assertEquals(dataStore.getShowLessInteractions(), []); 642 + assertEquals(dataStore.getShowMoreInteractions(), []); 643 + }); 644 + 645 + it("should append show-less interactions in order", () => { 646 + const dataStore = new DataStore(); 647 + dataStore.addShowLessInteraction(interactionA); 648 + assertEquals(dataStore.getShowLessInteractions(), [interactionA]); 649 + dataStore.addShowLessInteraction(interactionB); 650 + assertEquals(dataStore.getShowLessInteractions(), [ 651 + interactionA, 652 + interactionB, 653 + ]); 654 + }); 655 + 656 + it("should append show-more interactions independently of show-less", () => { 657 + const dataStore = new DataStore(); 658 + dataStore.addShowLessInteraction(interactionA); 659 + dataStore.addShowMoreInteraction(interactionC); 660 + assertEquals(dataStore.getShowLessInteractions(), [interactionA]); 661 + assertEquals(dataStore.getShowMoreInteractions(), [interactionC]); 662 + }); 663 + }); 664 + 665 + t.describe("setPosts bulk insert", (it) => { 666 + it("should insert multiple posts and emit setPost for each", () => { 667 + const dataStore = new DataStore(); 668 + const emittedUris = []; 669 + dataStore.on("setPost", (post) => { 670 + emittedUris.push(post.uri); 671 + }); 672 + const posts = [ 673 + { uri: "at://did:test/app.bsky.feed.post/1", record: { text: "one" } }, 674 + { uri: "at://did:test/app.bsky.feed.post/2", record: { text: "two" } }, 675 + { uri: "at://did:test/app.bsky.feed.post/3", record: { text: "three" } }, 676 + ]; 677 + dataStore.setPosts(posts); 678 + posts.forEach((post) => { 679 + assertEquals(dataStore.hasPost(post.uri), true); 680 + assertEquals(dataStore.getPost(post.uri), post); 681 + }); 682 + assertEquals( 683 + emittedUris, 684 + posts.map((post) => post.uri), 685 + ); 686 + }); 687 + 688 + it("should match setPost behavior when given a single post", () => { 689 + const dataStoreA = new DataStore(); 690 + const dataStoreB = new DataStore(); 691 + const post = { 692 + uri: "at://did:test/app.bsky.feed.post/solo", 693 + record: { text: "solo" }, 694 + }; 695 + dataStoreA.setPost(post.uri, post); 696 + dataStoreB.setPosts([post]); 697 + assertEquals(dataStoreA.getPost(post.uri), dataStoreB.getPost(post.uri)); 698 + assertEquals(dataStoreA.getAllPosts(), dataStoreB.getAllPosts()); 699 + }); 700 + }); 701 + 702 + t.describe("Event emission for set* methods", (it) => { 703 + it("should emit setNotifications when notifications are set", () => { 704 + const dataStore = new DataStore(); 705 + let emitted = null; 706 + dataStore.on("setNotifications", (value) => { 707 + emitted = value; 708 + }); 709 + const notifications = [{ uri: "at://did:test/notif/1" }]; 710 + dataStore.setNotifications(notifications); 711 + assertEquals(emitted, notifications); 712 + }); 713 + 714 + it("should emit setProfileSearchResults when profile search results are set", () => { 715 + const dataStore = new DataStore(); 716 + let emitted = null; 717 + dataStore.on("setProfileSearchResults", (value) => { 718 + emitted = value; 719 + }); 720 + const results = { actors: [{ did: "did:plc:a" }], cursor: "next" }; 721 + dataStore.setProfileSearchResults(results); 722 + assertEquals(emitted, results); 723 + }); 724 + 725 + it("should emit setFeedGenerator when a feed generator is set", () => { 726 + const dataStore = new DataStore(); 727 + let emitted = null; 728 + dataStore.on("setFeedGenerator", (value) => { 729 + emitted = value; 730 + }); 731 + const feedGenerator = { 732 + uri: "at://did:test/app.bsky.feed.generator/test", 733 + displayName: "Test", 734 + }; 735 + dataStore.setFeedGenerator(feedGenerator.uri, feedGenerator); 736 + assertEquals(emitted, feedGenerator); 737 + }); 738 + }); 739 + 740 + t.describe("Trivial accessor pairs", (it) => { 741 + const accessors = [ 742 + { name: "ProfileFollowers", key: "did:plc:a", value: { followers: [] } }, 743 + { name: "ProfileFollows", key: "did:plc:b", value: { follows: [] } }, 744 + { name: "ProfileChatStatus", key: "did:plc:c", value: { status: "all" } }, 745 + { 746 + name: "PluginFilteredFeedItems", 747 + key: "at://did:test/app.bsky.feed.generator/x", 748 + value: { items: ["a", "b"] }, 749 + }, 750 + { 751 + name: "AuthorFeed", 752 + key: "at://did:test/app.bsky.feed.generator/author", 753 + value: { feed: [], cursor: null }, 754 + }, 755 + { 756 + name: "FeedGenerator", 757 + key: "at://did:test/app.bsky.feed.generator/fg", 758 + value: { uri: "at://did:test/app.bsky.feed.generator/fg" }, 759 + }, 760 + { name: "ActorFeeds", key: "did:plc:d", value: { feeds: [] } }, 761 + { name: "HashtagFeed", key: "#test", value: { posts: [] } }, 762 + ]; 763 + 764 + for (const accessor of accessors) { 765 + it(`should has/get/set/clear ${accessor.name}`, () => { 766 + const dataStore = new DataStore(); 767 + const hasFn = `has${accessor.name}`; 768 + const getFn = `get${accessor.name}`; 769 + const setFn = `set${accessor.name}`; 770 + const clearFn = `clear${accessor.name}`; 771 + assertEquals(dataStore[hasFn](accessor.key), false); 772 + assertEquals(dataStore[getFn](accessor.key), undefined); 773 + dataStore[setFn](accessor.key, accessor.value); 774 + assertEquals(dataStore[hasFn](accessor.key), true); 775 + assertEquals(dataStore[getFn](accessor.key), accessor.value); 776 + dataStore[clearFn](accessor.key); 777 + assertEquals(dataStore[hasFn](accessor.key), false); 778 + assertEquals(dataStore[getFn](accessor.key), undefined); 779 + }); 780 + } 781 + 782 + it("should has/get/set/clear PinnedFeedGenerators (singleton)", () => { 783 + const dataStore = new DataStore(); 784 + const value = [{ uri: "at://did:test/app.bsky.feed.generator/pinned" }]; 785 + assertEquals(dataStore.hasPinnedFeedGenerators(), false); 786 + assertEquals(dataStore.getPinnedFeedGenerators(), null); 787 + dataStore.setPinnedFeedGenerators(value); 788 + assertEquals(dataStore.hasPinnedFeedGenerators(), true); 789 + assertEquals(dataStore.getPinnedFeedGenerators(), value); 790 + dataStore.clearPinnedFeedGenerators(); 791 + assertEquals(dataStore.hasPinnedFeedGenerators(), false); 792 + assertEquals(dataStore.getPinnedFeedGenerators(), null); 793 + }); 794 + }); 795 + 413 796 await t.run();
+1341
tests/unit/specs/dataLayer/mutations.test.js
··· 1365 1365 }); 1366 1366 }); 1367 1367 1368 + t.describe("blockProfile", (it) => { 1369 + const profile = { 1370 + did: "did:plc:target", 1371 + handle: "target.bsky.social", 1372 + viewer: {}, 1373 + }; 1374 + const blockUri = "at://did:plc:me/app.bsky.graph.block/123"; 1375 + 1376 + function setup(mockApi = {}) { 1377 + const dataStore = new DataStore(); 1378 + const patchStore = new PatchStore(); 1379 + const mockPreferencesProvider = { 1380 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1381 + }; 1382 + const mutations = new Mutations( 1383 + { blockActor: async () => ({ uri: blockUri }), ...mockApi }, 1384 + dataStore, 1385 + patchStore, 1386 + mockPreferencesProvider, 1387 + ); 1388 + return { mutations, dataStore }; 1389 + } 1390 + 1391 + it("should set viewer.blocking on the profile", async () => { 1392 + const { mutations, dataStore } = setup(); 1393 + await mutations.blockProfile(profile); 1394 + assertEquals(dataStore.getProfile(profile.did).viewer.blocking, blockUri); 1395 + }); 1396 + 1397 + it("should prepend blocked profile to the cached list", async () => { 1398 + const { mutations, dataStore } = setup(); 1399 + const existing = { 1400 + did: "did:plc:other", 1401 + viewer: { blocking: "at://existing-block" }, 1402 + }; 1403 + dataStore.setBlockedProfiles({ blocks: [existing], cursor: "abc" }); 1404 + 1405 + await mutations.blockProfile(profile); 1406 + 1407 + const stored = dataStore.getBlockedProfiles(); 1408 + assertEquals(stored.blocks.length, 2); 1409 + assertEquals(stored.blocks[0].did, profile.did); 1410 + assertEquals(stored.blocks[0].viewer.blocking, blockUri); 1411 + assertEquals(stored.blocks[1].did, existing.did); 1412 + assertEquals(stored.cursor, "abc"); 1413 + }); 1414 + 1415 + it("should not duplicate when already present in the cached list", async () => { 1416 + const { mutations, dataStore } = setup(); 1417 + dataStore.setBlockedProfiles({ 1418 + blocks: [{ ...profile, viewer: { blocking: blockUri } }], 1419 + cursor: null, 1420 + }); 1421 + 1422 + await mutations.blockProfile(profile); 1423 + 1424 + assertEquals(dataStore.getBlockedProfiles().blocks.length, 1); 1425 + }); 1426 + 1427 + it("should not initialize the cached list if it was not loaded", async () => { 1428 + const { mutations, dataStore } = setup(); 1429 + await mutations.blockProfile(profile); 1430 + assertEquals(dataStore.getBlockedProfiles(), null); 1431 + }); 1432 + 1433 + it("should update author viewer.blocking on cached posts by that author", async () => { 1434 + const { mutations, dataStore } = setup(); 1435 + const post = { 1436 + uri: "at://did:plc:target/app.bsky.feed.post/1", 1437 + author: { did: profile.did, viewer: {} }, 1438 + }; 1439 + const otherPost = { 1440 + uri: "at://did:plc:someone/app.bsky.feed.post/1", 1441 + author: { did: "did:plc:someone", viewer: {} }, 1442 + }; 1443 + dataStore.setPost(post.uri, post); 1444 + dataStore.setPost(otherPost.uri, otherPost); 1445 + 1446 + await mutations.blockProfile(profile); 1447 + 1448 + assertEquals(dataStore.getPost(post.uri).author.viewer.blocking, blockUri); 1449 + assertEquals( 1450 + dataStore.getPost(otherPost.uri).author.viewer.blocking, 1451 + undefined, 1452 + ); 1453 + }); 1454 + }); 1455 + 1456 + t.describe("unblockProfile", (it) => { 1457 + const profile = { 1458 + did: "did:plc:target", 1459 + handle: "target.bsky.social", 1460 + viewer: { blocking: "at://did:plc:me/app.bsky.graph.block/123" }, 1461 + }; 1462 + 1463 + function setup(mockApi = {}) { 1464 + const dataStore = new DataStore(); 1465 + const patchStore = new PatchStore(); 1466 + const mockPreferencesProvider = { 1467 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1468 + }; 1469 + const mutations = new Mutations( 1470 + { unblockActor: async () => ({}), ...mockApi }, 1471 + dataStore, 1472 + patchStore, 1473 + mockPreferencesProvider, 1474 + ); 1475 + return { mutations, dataStore }; 1476 + } 1477 + 1478 + it("should clear viewer.blocking on the profile", async () => { 1479 + const { mutations, dataStore } = setup(); 1480 + await mutations.unblockProfile(profile); 1481 + assertEquals(dataStore.getProfile(profile.did).viewer.blocking, null); 1482 + }); 1483 + 1484 + it("should remove profile from the cached list", async () => { 1485 + const { mutations, dataStore } = setup(); 1486 + const other = { 1487 + did: "did:plc:other", 1488 + viewer: { blocking: "at://other-block" }, 1489 + }; 1490 + dataStore.setBlockedProfiles({ 1491 + blocks: [profile, other], 1492 + cursor: "abc", 1493 + }); 1494 + 1495 + await mutations.unblockProfile(profile); 1496 + 1497 + const stored = dataStore.getBlockedProfiles(); 1498 + assertEquals(stored.blocks.length, 1); 1499 + assertEquals(stored.blocks[0].did, other.did); 1500 + assertEquals(stored.cursor, "abc"); 1501 + }); 1502 + 1503 + it("should be a no-op on the cached list when not present", async () => { 1504 + const { mutations, dataStore } = setup(); 1505 + const other = { 1506 + did: "did:plc:other", 1507 + viewer: { blocking: "at://other-block" }, 1508 + }; 1509 + dataStore.setBlockedProfiles({ blocks: [other], cursor: null }); 1510 + 1511 + await mutations.unblockProfile(profile); 1512 + 1513 + assertEquals(dataStore.getBlockedProfiles().blocks.length, 1); 1514 + }); 1515 + 1516 + it("should clear author viewer.blocking on cached posts by that author", async () => { 1517 + const { mutations, dataStore } = setup(); 1518 + const post = { 1519 + uri: "at://did:plc:target/app.bsky.feed.post/1", 1520 + author: { did: profile.did, viewer: { blocking: "at://old" } }, 1521 + }; 1522 + dataStore.setPost(post.uri, post); 1523 + 1524 + await mutations.unblockProfile(profile); 1525 + 1526 + assertEquals(dataStore.getPost(post.uri).author.viewer.blocking, null); 1527 + }); 1528 + }); 1529 + 1530 + t.describe("addBookmark", (it) => { 1531 + const testPost = { 1532 + uri: "at://did:test/app.bsky.feed.post/test", 1533 + bookmarkCount: 2, 1534 + viewer: { bookmarked: false }, 1535 + }; 1536 + 1537 + function setup(mockApi = {}) { 1538 + const dataStore = new DataStore(); 1539 + const patchStore = new PatchStore(); 1540 + const mockPreferencesProvider = { 1541 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1542 + }; 1543 + const mutations = new Mutations( 1544 + { createBookmark: async () => ({}), ...mockApi }, 1545 + dataStore, 1546 + patchStore, 1547 + mockPreferencesProvider, 1548 + ); 1549 + return { mutations, dataStore, patchStore }; 1550 + } 1551 + 1552 + it("should add optimistic patch immediately", () => { 1553 + const { mutations, patchStore } = setup({ 1554 + createBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), 1555 + }); 1556 + mutations.addBookmark(testPost); 1557 + const patched = patchStore.applyPostPatches(testPost); 1558 + assertEquals(patched.viewer.bookmarked, true); 1559 + assertEquals(patched.bookmarkCount, 3); 1560 + }); 1561 + 1562 + it("should update dataStore and remove patch on success", async () => { 1563 + const { mutations, dataStore, patchStore } = setup(); 1564 + await mutations.addBookmark(testPost); 1565 + const stored = dataStore.getPost(testPost.uri); 1566 + assertEquals(stored.viewer.bookmarked, true); 1567 + assertEquals(stored.bookmarkCount, 3); 1568 + assertEquals(patchStore.applyPostPatches(stored), stored); 1569 + }); 1570 + 1571 + it("should prepend post to the cached bookmarks feed", async () => { 1572 + const { mutations, dataStore } = setup(); 1573 + const existingItem = { 1574 + post: { uri: "at://did:test/app.bsky.feed.post/other" }, 1575 + }; 1576 + dataStore.setBookmarks({ feed: [existingItem], cursor: "abc" }); 1577 + 1578 + await mutations.addBookmark(testPost); 1579 + 1580 + const bookmarks = dataStore.getBookmarks(); 1581 + assertEquals(bookmarks.feed.length, 2); 1582 + assertEquals(bookmarks.feed[0].post.uri, testPost.uri); 1583 + assertEquals(bookmarks.feed[1].post.uri, existingItem.post.uri); 1584 + assertEquals(bookmarks.cursor, "abc"); 1585 + }); 1586 + 1587 + it("should not initialize the bookmarks feed if it was not loaded", async () => { 1588 + const { mutations, dataStore } = setup(); 1589 + await mutations.addBookmark(testPost); 1590 + assertEquals(dataStore.getBookmarks(), null); 1591 + }); 1592 + }); 1593 + 1594 + t.describe("removeBookmark", (it) => { 1595 + const testPost = { 1596 + uri: "at://did:test/app.bsky.feed.post/test", 1597 + bookmarkCount: 3, 1598 + viewer: { bookmarked: true }, 1599 + }; 1600 + 1601 + function setup(mockApi = {}) { 1602 + const dataStore = new DataStore(); 1603 + const patchStore = new PatchStore(); 1604 + const mockPreferencesProvider = { 1605 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1606 + }; 1607 + const mutations = new Mutations( 1608 + { deleteBookmark: async () => ({}), ...mockApi }, 1609 + dataStore, 1610 + patchStore, 1611 + mockPreferencesProvider, 1612 + ); 1613 + return { mutations, dataStore, patchStore }; 1614 + } 1615 + 1616 + it("should add optimistic patch immediately", () => { 1617 + const { mutations, patchStore } = setup({ 1618 + deleteBookmark: () => new Promise((resolve) => setTimeout(resolve, 100)), 1619 + }); 1620 + mutations.removeBookmark(testPost); 1621 + const patched = patchStore.applyPostPatches(testPost); 1622 + assertEquals(patched.viewer.bookmarked, false); 1623 + assertEquals(patched.bookmarkCount, 2); 1624 + }); 1625 + 1626 + it("should update dataStore and remove patch on success", async () => { 1627 + const { mutations, dataStore, patchStore } = setup(); 1628 + await mutations.removeBookmark(testPost); 1629 + const stored = dataStore.getPost(testPost.uri); 1630 + assertEquals(stored.viewer.bookmarked, false); 1631 + assertEquals(stored.bookmarkCount, 2); 1632 + assertEquals(patchStore.applyPostPatches(stored), stored); 1633 + }); 1634 + 1635 + it("should remove post from the cached bookmarks feed", async () => { 1636 + const { mutations, dataStore } = setup(); 1637 + const otherItem = { 1638 + post: { uri: "at://did:test/app.bsky.feed.post/other" }, 1639 + }; 1640 + dataStore.setBookmarks({ 1641 + feed: [{ post: testPost }, otherItem], 1642 + cursor: "abc", 1643 + }); 1644 + 1645 + await mutations.removeBookmark(testPost); 1646 + 1647 + const bookmarks = dataStore.getBookmarks(); 1648 + assertEquals(bookmarks.feed.length, 1); 1649 + assertEquals(bookmarks.feed[0].post.uri, otherItem.post.uri); 1650 + assertEquals(bookmarks.cursor, "abc"); 1651 + }); 1652 + }); 1653 + 1654 + t.describe("createRepost", (it) => { 1655 + const currentUser = { 1656 + did: "did:plc:me", 1657 + handle: "me.test", 1658 + viewer: {}, 1659 + }; 1660 + const testPost = { 1661 + uri: "at://did:plc:author/app.bsky.feed.post/1", 1662 + cid: "cid-1", 1663 + author: { did: "did:plc:author", viewer: {} }, 1664 + repostCount: 4, 1665 + viewer: { repost: null }, 1666 + }; 1667 + 1668 + function setup(mockApi = {}, { authorFeed } = {}) { 1669 + const dataStore = new DataStore(); 1670 + const patchStore = new PatchStore(); 1671 + const mockPreferencesProvider = { 1672 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1673 + }; 1674 + dataStore.setCurrentUser(currentUser); 1675 + if (authorFeed) { 1676 + dataStore.setAuthorFeed(`${currentUser.did}-posts`, authorFeed); 1677 + } 1678 + const mutations = new Mutations( 1679 + { 1680 + createRepostRecord: async () => ({ 1681 + uri: "at://did:plc:me/app.bsky.feed.repost/abc", 1682 + cid: "repost-cid", 1683 + }), 1684 + ...mockApi, 1685 + }, 1686 + dataStore, 1687 + patchStore, 1688 + mockPreferencesProvider, 1689 + ); 1690 + return { mutations, dataStore, patchStore }; 1691 + } 1692 + 1693 + it("should add optimistic patch immediately", () => { 1694 + const { mutations, patchStore } = setup({ 1695 + createRepostRecord: () => 1696 + new Promise((resolve) => 1697 + setTimeout(() => resolve({ uri: "x", cid: "y" }), 100), 1698 + ), 1699 + }); 1700 + mutations.createRepost(testPost); 1701 + const patched = patchStore.applyPostPatches(testPost); 1702 + assertEquals(patched.repostCount, 5); 1703 + assertEquals(patched.viewer.repost, "fake repost"); 1704 + }); 1705 + 1706 + it("should update dataStore with repost uri and incremented count", async () => { 1707 + const { mutations, dataStore } = setup(); 1708 + await mutations.createRepost(testPost); 1709 + const stored = dataStore.getPost(testPost.uri); 1710 + assertEquals( 1711 + stored.viewer.repost, 1712 + "at://did:plc:me/app.bsky.feed.repost/abc", 1713 + ); 1714 + assertEquals(stored.repostCount, 5); 1715 + }); 1716 + 1717 + it("should add a reasonRepost feed item to the current user's author feed", async () => { 1718 + const { mutations, dataStore } = setup( 1719 + {}, 1720 + { authorFeed: { feed: [], cursor: "c1" } }, 1721 + ); 1722 + await mutations.createRepost(testPost); 1723 + const feed = dataStore.getAuthorFeed(`${currentUser.did}-posts`); 1724 + assertEquals(feed.feed.length, 1); 1725 + assertEquals(feed.feed[0].post.uri, testPost.uri); 1726 + assertEquals(feed.feed[0].reason.$type, "app.bsky.feed.defs#reasonRepost"); 1727 + assertEquals(feed.feed[0].reason.by.did, currentUser.did); 1728 + assertEquals( 1729 + feed.feed[0].reason.uri, 1730 + "at://did:plc:me/app.bsky.feed.repost/abc", 1731 + ); 1732 + assertEquals(feed.cursor, "c1"); 1733 + }); 1734 + }); 1735 + 1736 + t.describe("deleteRepost", (it) => { 1737 + const currentUser = { 1738 + did: "did:plc:me", 1739 + handle: "me.test", 1740 + viewer: {}, 1741 + }; 1742 + const repostUri = "at://did:plc:me/app.bsky.feed.repost/abc"; 1743 + const testPost = { 1744 + uri: "at://did:plc:author/app.bsky.feed.post/1", 1745 + cid: "cid-1", 1746 + author: { did: "did:plc:author", viewer: {} }, 1747 + repostCount: 5, 1748 + viewer: { repost: repostUri }, 1749 + }; 1750 + 1751 + function setup(mockApi = {}, { authorFeed } = {}) { 1752 + const dataStore = new DataStore(); 1753 + const patchStore = new PatchStore(); 1754 + const mockPreferencesProvider = { 1755 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1756 + }; 1757 + dataStore.setCurrentUser(currentUser); 1758 + if (authorFeed) { 1759 + dataStore.setAuthorFeed(`${currentUser.did}-posts`, authorFeed); 1760 + } 1761 + const mutations = new Mutations( 1762 + { deleteRepostRecord: async () => ({}), ...mockApi }, 1763 + dataStore, 1764 + patchStore, 1765 + mockPreferencesProvider, 1766 + ); 1767 + return { mutations, dataStore, patchStore }; 1768 + } 1769 + 1770 + it("should add optimistic patch immediately", () => { 1771 + const { mutations, patchStore } = setup({ 1772 + deleteRepostRecord: () => 1773 + new Promise((resolve) => setTimeout(resolve, 100)), 1774 + }); 1775 + mutations.deleteRepost(testPost); 1776 + const patched = patchStore.applyPostPatches(testPost); 1777 + assertEquals(patched.repostCount, 4); 1778 + assertEquals(patched.viewer.repost, null); 1779 + }); 1780 + 1781 + it("should update dataStore clearing repost uri and decrementing count", async () => { 1782 + const { mutations, dataStore } = setup(); 1783 + await mutations.deleteRepost(testPost); 1784 + const stored = dataStore.getPost(testPost.uri); 1785 + assertEquals(stored.viewer.repost, null); 1786 + assertEquals(stored.repostCount, 4); 1787 + }); 1788 + 1789 + it("should remove the matching repost feed item from the author feed", async () => { 1790 + const matchingItem = { 1791 + post: testPost, 1792 + reason: { 1793 + $type: "app.bsky.feed.defs#reasonRepost", 1794 + uri: repostUri, 1795 + }, 1796 + }; 1797 + const otherItem = { 1798 + post: { 1799 + uri: "at://did:plc:other/app.bsky.feed.post/2", 1800 + }, 1801 + }; 1802 + const { mutations, dataStore } = setup( 1803 + {}, 1804 + { authorFeed: { feed: [matchingItem, otherItem], cursor: "c1" } }, 1805 + ); 1806 + 1807 + await mutations.deleteRepost(testPost); 1808 + 1809 + const feed = dataStore.getAuthorFeed(`${currentUser.did}-posts`); 1810 + assertEquals(feed.feed.length, 1); 1811 + assertEquals(feed.feed[0].post.uri, otherItem.post.uri); 1812 + assertEquals(feed.cursor, "c1"); 1813 + }); 1814 + }); 1815 + 1816 + t.describe("pinFeed", (it) => { 1817 + const feedUri = "at://did:plc:feed/app.bsky.feed.generator/cool"; 1818 + 1819 + function setupWithPreferences(preferencesObj) { 1820 + let updatedPreferences = null; 1821 + const preferences = new Preferences(preferencesObj, []); 1822 + const mockPreferencesProvider = { 1823 + requirePreferences: () => preferences, 1824 + updatePreferences: async (prefs) => { 1825 + updatedPreferences = prefs; 1826 + }, 1827 + }; 1828 + const dataStore = new DataStore(); 1829 + const patchStore = new PatchStore(); 1830 + const mutations = new Mutations( 1831 + {}, 1832 + dataStore, 1833 + patchStore, 1834 + mockPreferencesProvider, 1835 + ); 1836 + return { 1837 + mutations, 1838 + patchStore, 1839 + getUpdatedPreferences: () => updatedPreferences, 1840 + }; 1841 + } 1842 + 1843 + it("should append a pinned saved-feed entry when not previously saved", async () => { 1844 + const { mutations, getUpdatedPreferences } = setupWithPreferences([ 1845 + { 1846 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 1847 + items: [], 1848 + }, 1849 + ]); 1850 + await mutations.pinFeed(feedUri); 1851 + const pinned = getUpdatedPreferences().getPinnedFeeds(); 1852 + assertEquals(pinned.length, 1); 1853 + assertEquals(pinned[0].value, feedUri); 1854 + assertEquals(pinned[0].pinned, true); 1855 + }); 1856 + 1857 + it("should pin an existing saved-feed entry without duplicating it", async () => { 1858 + const { mutations, getUpdatedPreferences } = setupWithPreferences([ 1859 + { 1860 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 1861 + items: [ 1862 + { id: "1", value: feedUri, type: "feed", pinned: false }, 1863 + { 1864 + id: "2", 1865 + value: "at://did:plc:feed/app.bsky.feed.generator/other", 1866 + type: "feed", 1867 + pinned: true, 1868 + }, 1869 + ], 1870 + }, 1871 + ]); 1872 + await mutations.pinFeed(feedUri); 1873 + const updated = getUpdatedPreferences(); 1874 + const allItems = updated.obj[0].items; 1875 + assertEquals(allItems.length, 2); 1876 + assertEquals(updated.isFeedPinned(feedUri), true); 1877 + }); 1878 + 1879 + it("should add an optimistic patch and remove it on success", async () => { 1880 + let updateResolve; 1881 + const updatePromise = new Promise((resolve) => { 1882 + updateResolve = resolve; 1883 + }); 1884 + const preferences = new Preferences( 1885 + [{ $type: "app.bsky.actor.defs#savedFeedsPrefV2", items: [] }], 1886 + [], 1887 + ); 1888 + const mockPreferencesProvider = { 1889 + requirePreferences: () => preferences, 1890 + updatePreferences: () => updatePromise, 1891 + }; 1892 + const dataStore = new DataStore(); 1893 + const patchStore = new PatchStore(); 1894 + const mutations = new Mutations( 1895 + {}, 1896 + dataStore, 1897 + patchStore, 1898 + mockPreferencesProvider, 1899 + ); 1900 + 1901 + const promise = mutations.pinFeed(feedUri); 1902 + const patches = patchStore._getPreferencePatches(); 1903 + assertEquals(patches.length, 1); 1904 + assertEquals(patches[0].body.type, "pinFeed"); 1905 + assertEquals(patches[0].body.feedUri, feedUri); 1906 + 1907 + updateResolve(); 1908 + await promise; 1909 + assertEquals(patchStore._getPreferencePatches().length, 0); 1910 + }); 1911 + }); 1912 + 1913 + t.describe("unpinFeed", (it) => { 1914 + const feedUri = "at://did:plc:feed/app.bsky.feed.generator/cool"; 1915 + 1916 + it("should clear the pinned flag on the saved-feed entry", async () => { 1917 + let updatedPreferences = null; 1918 + const preferences = new Preferences( 1919 + [ 1920 + { 1921 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 1922 + items: [{ id: "1", value: feedUri, type: "feed", pinned: true }], 1923 + }, 1924 + ], 1925 + [], 1926 + ); 1927 + const mockPreferencesProvider = { 1928 + requirePreferences: () => preferences, 1929 + updatePreferences: async (prefs) => { 1930 + updatedPreferences = prefs; 1931 + }, 1932 + }; 1933 + const dataStore = new DataStore(); 1934 + const patchStore = new PatchStore(); 1935 + const mutations = new Mutations( 1936 + {}, 1937 + dataStore, 1938 + patchStore, 1939 + mockPreferencesProvider, 1940 + ); 1941 + 1942 + await mutations.unpinFeed(feedUri); 1943 + assertEquals(updatedPreferences.isFeedPinned(feedUri), false); 1944 + assertEquals(updatedPreferences.obj[0].items.length, 1); 1945 + }); 1946 + 1947 + it("should add an optimistic patch and remove it on success", async () => { 1948 + let updateResolve; 1949 + const updatePromise = new Promise((resolve) => { 1950 + updateResolve = resolve; 1951 + }); 1952 + const preferences = new Preferences( 1953 + [ 1954 + { 1955 + $type: "app.bsky.actor.defs#savedFeedsPrefV2", 1956 + items: [{ id: "1", value: feedUri, type: "feed", pinned: true }], 1957 + }, 1958 + ], 1959 + [], 1960 + ); 1961 + const mockPreferencesProvider = { 1962 + requirePreferences: () => preferences, 1963 + updatePreferences: () => updatePromise, 1964 + }; 1965 + const dataStore = new DataStore(); 1966 + const patchStore = new PatchStore(); 1967 + const mutations = new Mutations( 1968 + {}, 1969 + dataStore, 1970 + patchStore, 1971 + mockPreferencesProvider, 1972 + ); 1973 + 1974 + const promise = mutations.unpinFeed(feedUri); 1975 + const patches = patchStore._getPreferencePatches(); 1976 + assertEquals(patches.length, 1); 1977 + assertEquals(patches[0].body.type, "unpinFeed"); 1978 + assertEquals(patches[0].body.feedUri, feedUri); 1979 + 1980 + updateResolve(); 1981 + await promise; 1982 + assertEquals(patchStore._getPreferencePatches().length, 0); 1983 + }); 1984 + }); 1985 + 1986 + t.describe("hidePost", (it) => { 1987 + const testPost = { uri: "at://did:plc:author/app.bsky.feed.post/1" }; 1988 + 1989 + it("should write a preference adding the post to the hidden list", async () => { 1990 + let updatedPreferences = null; 1991 + const preferences = new Preferences([], []); 1992 + const mockPreferencesProvider = { 1993 + requirePreferences: () => preferences, 1994 + updatePreferences: async (prefs) => { 1995 + updatedPreferences = prefs; 1996 + }, 1997 + }; 1998 + const dataStore = new DataStore(); 1999 + const patchStore = new PatchStore(); 2000 + const mutations = new Mutations( 2001 + {}, 2002 + dataStore, 2003 + patchStore, 2004 + mockPreferencesProvider, 2005 + ); 2006 + 2007 + await mutations.hidePost(testPost); 2008 + 2009 + assertEquals(updatedPreferences.isPostHidden(testPost.uri), true); 2010 + }); 2011 + 2012 + it("should add an optimistic post patch and remove it on success", async () => { 2013 + let updateResolve; 2014 + const updatePromise = new Promise((resolve) => { 2015 + updateResolve = resolve; 2016 + }); 2017 + const preferences = new Preferences([], []); 2018 + const mockPreferencesProvider = { 2019 + requirePreferences: () => preferences, 2020 + updatePreferences: () => updatePromise, 2021 + }; 2022 + const dataStore = new DataStore(); 2023 + const patchStore = new PatchStore(); 2024 + const mutations = new Mutations( 2025 + {}, 2026 + dataStore, 2027 + patchStore, 2028 + mockPreferencesProvider, 2029 + ); 2030 + 2031 + const promise = mutations.hidePost(testPost); 2032 + const patches = patchStore._getPostPatches(testPost.uri); 2033 + assertEquals(patches.length, 1); 2034 + assertEquals(patches[0].body.type, "hidePost"); 2035 + 2036 + updateResolve(); 2037 + await promise; 2038 + assertEquals(patchStore._getPostPatches(testPost.uri).length, 0); 2039 + }); 2040 + }); 2041 + 2042 + t.describe("updateMutedWord", (it) => { 2043 + it("should call updatePreferences with the word updated", async () => { 2044 + let updatedPreferences = null; 2045 + const existingPrefs = new Preferences( 2046 + [ 2047 + { 2048 + $type: "app.bsky.actor.defs#mutedWordsPref", 2049 + items: [ 2050 + { 2051 + id: "word-1", 2052 + value: "old-value", 2053 + targets: ["content"], 2054 + actorTarget: "all", 2055 + }, 2056 + ], 2057 + }, 2058 + ], 2059 + [], 2060 + ); 2061 + const mockPreferencesProvider = { 2062 + requirePreferences: () => existingPrefs, 2063 + updatePreferences: async (prefs) => { 2064 + updatedPreferences = prefs; 2065 + }, 2066 + }; 2067 + const dataStore = new DataStore(); 2068 + const patchStore = new PatchStore(); 2069 + const mutations = new Mutations( 2070 + {}, 2071 + dataStore, 2072 + patchStore, 2073 + mockPreferencesProvider, 2074 + ); 2075 + 2076 + await mutations.updateMutedWord("word-1", { 2077 + value: "new-value", 2078 + targets: ["tag"], 2079 + }); 2080 + 2081 + const words = updatedPreferences.getMutedWords(); 2082 + assertEquals(words.length, 1); 2083 + assertEquals(words[0].value, "new-value"); 2084 + assertEquals(words[0].targets[0], "tag"); 2085 + assertEquals(words[0].actorTarget, "all"); 2086 + }); 2087 + }); 2088 + 2089 + t.describe("updatePostNotificationSubscription", (it) => { 2090 + const profile = { 2091 + did: "did:plc:target", 2092 + handle: "target.bsky.social", 2093 + viewer: {}, 2094 + }; 2095 + 2096 + it("should set viewer.activitySubscription on the profile", async () => { 2097 + const subscription = { post: true, reply: false }; 2098 + const dataStore = new DataStore(); 2099 + const patchStore = new PatchStore(); 2100 + const mockPreferencesProvider = { 2101 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2102 + }; 2103 + let calledWith = null; 2104 + const mutations = new Mutations( 2105 + { 2106 + putActivitySubscription: async (did, sub) => { 2107 + calledWith = { did, sub }; 2108 + }, 2109 + }, 2110 + dataStore, 2111 + patchStore, 2112 + mockPreferencesProvider, 2113 + ); 2114 + 2115 + await mutations.updatePostNotificationSubscription(profile, subscription); 2116 + 2117 + assertEquals(calledWith.did, profile.did); 2118 + assertEquals(calledWith.sub, subscription); 2119 + assertEquals( 2120 + dataStore.getProfile(profile.did).viewer.activitySubscription, 2121 + subscription, 2122 + ); 2123 + }); 2124 + 2125 + it("should remove the patch on failure and rethrow", async () => { 2126 + const dataStore = new DataStore(); 2127 + const patchStore = new PatchStore(); 2128 + const mockPreferencesProvider = { 2129 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2130 + }; 2131 + const mutations = new Mutations( 2132 + { 2133 + putActivitySubscription: async () => { 2134 + throw new Error("api error"); 2135 + }, 2136 + }, 2137 + dataStore, 2138 + patchStore, 2139 + mockPreferencesProvider, 2140 + ); 2141 + 2142 + let threw = false; 2143 + try { 2144 + await mutations.updatePostNotificationSubscription(profile, { 2145 + post: true, 2146 + }); 2147 + } catch (e) { 2148 + threw = true; 2149 + } 2150 + assertEquals(threw, true); 2151 + assertEquals(patchStore._getProfilePatches(profile.did).length, 0); 2152 + }); 2153 + }); 2154 + 2155 + t.describe("createPost", (it) => { 2156 + const currentUserDid = "did:plc:me"; 2157 + const newPostUri = `at://${currentUserDid}/app.bsky.feed.post/new`; 2158 + 2159 + function setup({ replyPostThread, authorFeed, replyAuthorFeed } = {}) { 2160 + const dataStore = new DataStore(); 2161 + const patchStore = new PatchStore(); 2162 + const mockPreferencesProvider = { 2163 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2164 + }; 2165 + const mutations = new Mutations( 2166 + {}, 2167 + dataStore, 2168 + patchStore, 2169 + mockPreferencesProvider, 2170 + ); 2171 + const fullPost = { 2172 + uri: newPostUri, 2173 + cid: "cid-new", 2174 + author: { did: currentUserDid, viewer: {} }, 2175 + record: { text: "hello" }, 2176 + viewer: {}, 2177 + }; 2178 + mutations.postCreator = { 2179 + createPost: async () => fullPost, 2180 + }; 2181 + if (replyPostThread) { 2182 + dataStore.setPostThread(replyPostThread.post.uri, replyPostThread); 2183 + } 2184 + if (authorFeed) { 2185 + dataStore.setAuthorFeed(`${currentUserDid}-posts`, authorFeed); 2186 + } 2187 + if (replyAuthorFeed) { 2188 + dataStore.setAuthorFeed(`${currentUserDid}-replies`, replyAuthorFeed); 2189 + } 2190 + return { mutations, dataStore, fullPost }; 2191 + } 2192 + 2193 + it("should store the new post and mark priorityReply", async () => { 2194 + const { mutations, dataStore } = setup(); 2195 + const result = await mutations.createPost({ postText: "hello" }); 2196 + assertEquals(result.uri, newPostUri); 2197 + const stored = dataStore.getPost(newPostUri); 2198 + assertEquals(stored.uri, newPostUri); 2199 + assertEquals(stored.viewer.priorityReply, true); 2200 + }); 2201 + 2202 + it("should add the new post to the author posts feed when loaded", async () => { 2203 + const { mutations, dataStore } = setup({ 2204 + authorFeed: { feed: [], cursor: "c1" }, 2205 + }); 2206 + await mutations.createPost({ postText: "hello" }); 2207 + const feed = dataStore.getAuthorFeed(`${currentUserDid}-posts`); 2208 + assertEquals(feed.feed.length, 1); 2209 + assertEquals(feed.feed[0].post.uri, newPostUri); 2210 + assertEquals(feed.cursor, "c1"); 2211 + }); 2212 + 2213 + it("should prepend the reply to the parent's post thread when present", async () => { 2214 + const replyTo = { 2215 + uri: "at://did:plc:other/app.bsky.feed.post/parent", 2216 + cid: "cid-parent", 2217 + }; 2218 + const replyRoot = replyTo; 2219 + const replyPostThread = { 2220 + post: replyTo, 2221 + replies: [ 2222 + { 2223 + $type: "app.bsky.feed.defs#threadViewPost", 2224 + post: { uri: "at://did:plc:other/app.bsky.feed.post/existing" }, 2225 + replies: [], 2226 + }, 2227 + ], 2228 + }; 2229 + const { mutations, dataStore } = setup({ 2230 + replyPostThread, 2231 + replyAuthorFeed: { feed: [], cursor: "c1" }, 2232 + }); 2233 + 2234 + await mutations.createPost({ postText: "hi", replyTo, replyRoot }); 2235 + 2236 + const updatedThread = dataStore.getPostThread(replyTo.uri); 2237 + assertEquals(updatedThread.replies.length, 2); 2238 + assertEquals(updatedThread.replies[0].post.uri, newPostUri); 2239 + const repliesFeed = dataStore.getAuthorFeed(`${currentUserDid}-replies`); 2240 + assertEquals(repliesFeed.feed.length, 1); 2241 + assertEquals(repliesFeed.feed[0].post.uri, newPostUri); 2242 + }); 2243 + }); 2244 + 2245 + t.describe("deletePost", (it) => { 2246 + it("should call api.deletePost and replace the stored post with a not-found post", async () => { 2247 + const post = { 2248 + uri: "at://did:plc:me/app.bsky.feed.post/abc", 2249 + cid: "cid-abc", 2250 + }; 2251 + let apiCalledWith = null; 2252 + const dataStore = new DataStore(); 2253 + const patchStore = new PatchStore(); 2254 + const mockPreferencesProvider = { 2255 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2256 + }; 2257 + dataStore.setPost(post.uri, { ...post, record: { text: "hi" } }); 2258 + const mutations = new Mutations( 2259 + { 2260 + deletePost: async (passed) => { 2261 + apiCalledWith = passed; 2262 + }, 2263 + }, 2264 + dataStore, 2265 + patchStore, 2266 + mockPreferencesProvider, 2267 + ); 2268 + 2269 + await mutations.deletePost(post); 2270 + 2271 + assertEquals(apiCalledWith, post); 2272 + const stored = dataStore.getPost(post.uri); 2273 + assertEquals(stored.uri, post.uri); 2274 + assertEquals(stored.$type, "app.bsky.feed.defs#notFoundPost"); 2275 + }); 2276 + }); 2277 + 2278 + t.describe("createMessage", (it) => { 2279 + const convoId = "convo-1"; 2280 + const sentMessage = { 2281 + id: "msg-1", 2282 + text: "hello", 2283 + sender: { did: "did:plc:me" }, 2284 + }; 2285 + 2286 + function setup({ convoMessages, convo } = {}) { 2287 + const dataStore = new DataStore(); 2288 + const patchStore = new PatchStore(); 2289 + const mockPreferencesProvider = { 2290 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2291 + }; 2292 + if (convoMessages) { 2293 + dataStore.setConvoMessages(convoId, convoMessages); 2294 + } 2295 + if (convo) { 2296 + dataStore.setConvo(convoId, convo); 2297 + } 2298 + const mutations = new Mutations( 2299 + { sendMessage: async () => sentMessage }, 2300 + dataStore, 2301 + patchStore, 2302 + mockPreferencesProvider, 2303 + ); 2304 + return { mutations, dataStore }; 2305 + } 2306 + 2307 + it("should store the new message and return it", async () => { 2308 + const { mutations, dataStore } = setup(); 2309 + const result = await mutations.createMessage(convoId, { text: "hello" }); 2310 + assertEquals(result, sentMessage); 2311 + assertEquals(dataStore.getMessage(sentMessage.id), sentMessage); 2312 + }); 2313 + 2314 + it("should prepend the message to the cached convo messages", async () => { 2315 + const existingMessage = { id: "msg-old", text: "earlier" }; 2316 + const { mutations, dataStore } = setup({ 2317 + convoMessages: { messages: [existingMessage], cursor: "c1" }, 2318 + }); 2319 + await mutations.createMessage(convoId, { text: "hello" }); 2320 + const stored = dataStore.getConvoMessages(convoId); 2321 + assertEquals(stored.messages.length, 2); 2322 + assertEquals(stored.messages[0].id, sentMessage.id); 2323 + assertEquals(stored.messages[1].id, existingMessage.id); 2324 + assertEquals(stored.cursor, "c1"); 2325 + }); 2326 + 2327 + it("should update the convo's lastMessage", async () => { 2328 + const convo = { id: convoId, unreadCount: 0 }; 2329 + const { mutations, dataStore } = setup({ convo }); 2330 + await mutations.createMessage(convoId, { text: "hello" }); 2331 + const stored = dataStore.getConvo(convoId); 2332 + assertEquals(stored.lastMessage.id, sentMessage.id); 2333 + assertEquals(stored.lastMessage.$type, "chat.bsky.convo.defs#messageView"); 2334 + }); 2335 + }); 2336 + 2337 + t.describe("acceptConvo", (it) => { 2338 + const convo = { id: "convo-1", status: "request" }; 2339 + 2340 + function setup({ convoList } = {}) { 2341 + const dataStore = new DataStore(); 2342 + const patchStore = new PatchStore(); 2343 + const mockPreferencesProvider = { 2344 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2345 + }; 2346 + if (convoList) { 2347 + dataStore.setConvoList(convoList); 2348 + } 2349 + let acceptCalledWith = null; 2350 + const mutations = new Mutations( 2351 + { 2352 + acceptConvo: async (id) => { 2353 + acceptCalledWith = id; 2354 + }, 2355 + }, 2356 + dataStore, 2357 + patchStore, 2358 + mockPreferencesProvider, 2359 + ); 2360 + return { mutations, dataStore, getAcceptArg: () => acceptCalledWith }; 2361 + } 2362 + 2363 + it("should set the convo status to accepted in the store", async () => { 2364 + const { mutations, dataStore, getAcceptArg } = setup(); 2365 + const result = await mutations.acceptConvo(convo); 2366 + assertEquals(getAcceptArg(), convo.id); 2367 + assertEquals(result.status, "accepted"); 2368 + assertEquals(dataStore.getConvo(convo.id).status, "accepted"); 2369 + }); 2370 + 2371 + it("should update the matching convo in the convo list", async () => { 2372 + const otherConvo = { id: "convo-2", status: "accepted" }; 2373 + const { mutations, dataStore } = setup({ 2374 + convoList: [convo, otherConvo], 2375 + }); 2376 + await mutations.acceptConvo(convo); 2377 + const list = dataStore.getConvoList(); 2378 + assertEquals(list.length, 2); 2379 + assertEquals(list.find((c) => c.id === convo.id).status, "accepted"); 2380 + assertEquals(list.find((c) => c.id === otherConvo.id).status, "accepted"); 2381 + }); 2382 + }); 2383 + 2384 + t.describe("rejectConvo", (it) => { 2385 + const convo = { id: "convo-1", status: "request" }; 2386 + 2387 + it("should clear the convo and remove it from the convo list", async () => { 2388 + const otherConvo = { id: "convo-2", status: "accepted" }; 2389 + const dataStore = new DataStore(); 2390 + const patchStore = new PatchStore(); 2391 + const mockPreferencesProvider = { 2392 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2393 + }; 2394 + dataStore.setConvo(convo.id, convo); 2395 + dataStore.setConvoList([convo, otherConvo]); 2396 + let leaveCalledWith = null; 2397 + const mutations = new Mutations( 2398 + { 2399 + leaveConvo: async (id) => { 2400 + leaveCalledWith = id; 2401 + }, 2402 + }, 2403 + dataStore, 2404 + patchStore, 2405 + mockPreferencesProvider, 2406 + ); 2407 + 2408 + await mutations.rejectConvo(convo); 2409 + 2410 + assertEquals(leaveCalledWith, convo.id); 2411 + assertEquals(dataStore.getConvo(convo.id), undefined); 2412 + const list = dataStore.getConvoList(); 2413 + assertEquals(list.length, 1); 2414 + assertEquals(list[0].id, otherConvo.id); 2415 + }); 2416 + }); 2417 + 2418 + t.describe("markConvoAsRead", (it) => { 2419 + it("should call api.markConvoAsRead and zero the unread count", async () => { 2420 + const convoId = "convo-1"; 2421 + const dataStore = new DataStore(); 2422 + const patchStore = new PatchStore(); 2423 + const mockPreferencesProvider = { 2424 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2425 + }; 2426 + dataStore.setConvo(convoId, { id: convoId, unreadCount: 4 }); 2427 + let calledWith = null; 2428 + const mutations = new Mutations( 2429 + { 2430 + markConvoAsRead: async (id) => { 2431 + calledWith = id; 2432 + }, 2433 + }, 2434 + dataStore, 2435 + patchStore, 2436 + mockPreferencesProvider, 2437 + ); 2438 + 2439 + await mutations.markConvoAsRead(convoId); 2440 + 2441 + assertEquals(calledWith, convoId); 2442 + assertEquals(dataStore.getConvo(convoId).unreadCount, 0); 2443 + }); 2444 + 2445 + it("should not throw when the convo is not cached", async () => { 2446 + const dataStore = new DataStore(); 2447 + const patchStore = new PatchStore(); 2448 + const mockPreferencesProvider = { 2449 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2450 + }; 2451 + const mutations = new Mutations( 2452 + { markConvoAsRead: async () => {} }, 2453 + dataStore, 2454 + patchStore, 2455 + mockPreferencesProvider, 2456 + ); 2457 + await mutations.markConvoAsRead("missing"); 2458 + assertEquals(dataStore.getConvo("missing"), undefined); 2459 + }); 2460 + }); 2461 + 2462 + t.describe("addMessageReaction", (it) => { 2463 + const convoId = "convo-1"; 2464 + const messageId = "msg-1"; 2465 + const currentUserDid = "did:plc:me"; 2466 + const emoji = "👍"; 2467 + const updatedMessage = { 2468 + id: messageId, 2469 + reactions: [{ value: emoji, sender: { did: currentUserDid } }], 2470 + }; 2471 + 2472 + function setup({ convo } = {}) { 2473 + const dataStore = new DataStore(); 2474 + const patchStore = new PatchStore(); 2475 + const mockPreferencesProvider = { 2476 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2477 + }; 2478 + if (convo) { 2479 + dataStore.setConvo(convoId, convo); 2480 + } 2481 + const mutations = new Mutations( 2482 + { addMessageReaction: async () => updatedMessage }, 2483 + dataStore, 2484 + patchStore, 2485 + mockPreferencesProvider, 2486 + ); 2487 + return { mutations, dataStore, patchStore }; 2488 + } 2489 + 2490 + it("should add an optimistic patch with the reaction", () => { 2491 + const { mutations, patchStore } = setup(); 2492 + mutations.addMessageReaction(convoId, messageId, emoji, currentUserDid); 2493 + const patches = patchStore._getMessagePatches(messageId); 2494 + assertEquals(patches.length, 1); 2495 + assertEquals(patches[0].body.type, "addReaction"); 2496 + assertEquals(patches[0].body.reaction.value, emoji); 2497 + assertEquals(patches[0].body.reaction.sender.did, currentUserDid); 2498 + }); 2499 + 2500 + it("should store the returned message and clear the patch on success", async () => { 2501 + const { mutations, dataStore, patchStore } = setup(); 2502 + await mutations.addMessageReaction( 2503 + convoId, 2504 + messageId, 2505 + emoji, 2506 + currentUserDid, 2507 + ); 2508 + assertEquals(dataStore.getMessage(messageId), updatedMessage); 2509 + assertEquals(patchStore._getMessagePatches(messageId).length, 0); 2510 + }); 2511 + 2512 + it("should update the convo's lastReaction when the convo is cached", async () => { 2513 + const { mutations, dataStore } = setup({ 2514 + convo: { id: convoId, unreadCount: 0 }, 2515 + }); 2516 + await mutations.addMessageReaction( 2517 + convoId, 2518 + messageId, 2519 + emoji, 2520 + currentUserDid, 2521 + ); 2522 + const convo = dataStore.getConvo(convoId); 2523 + assertEquals( 2524 + convo.lastReaction.$type, 2525 + "chat.bsky.convo.defs#messageAndReactionView", 2526 + ); 2527 + assertEquals(convo.lastReaction.message.id, messageId); 2528 + assertEquals(convo.lastReaction.reaction.value, emoji); 2529 + }); 2530 + }); 2531 + 2532 + t.describe("removeMessageReaction", (it) => { 2533 + const convoId = "convo-1"; 2534 + const messageId = "msg-1"; 2535 + const currentUserDid = "did:plc:me"; 2536 + const emoji = "👍"; 2537 + const updatedMessage = { id: messageId, reactions: [] }; 2538 + 2539 + function setup({ convo } = {}) { 2540 + const dataStore = new DataStore(); 2541 + const patchStore = new PatchStore(); 2542 + const mockPreferencesProvider = { 2543 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2544 + }; 2545 + if (convo) { 2546 + dataStore.setConvo(convoId, convo); 2547 + } 2548 + const mutations = new Mutations( 2549 + { removeMessageReaction: async () => updatedMessage }, 2550 + dataStore, 2551 + patchStore, 2552 + mockPreferencesProvider, 2553 + ); 2554 + return { mutations, dataStore, patchStore }; 2555 + } 2556 + 2557 + it("should add an optimistic removeReaction patch", () => { 2558 + const { mutations, patchStore } = setup(); 2559 + mutations.removeMessageReaction(convoId, messageId, emoji, currentUserDid); 2560 + const patches = patchStore._getMessagePatches(messageId); 2561 + assertEquals(patches.length, 1); 2562 + assertEquals(patches[0].body.type, "removeReaction"); 2563 + assertEquals(patches[0].body.value, emoji); 2564 + assertEquals(patches[0].body.currentUserDid, currentUserDid); 2565 + }); 2566 + 2567 + it("should store the returned message and clear the patch on success", async () => { 2568 + const { mutations, dataStore, patchStore } = setup(); 2569 + await mutations.removeMessageReaction( 2570 + convoId, 2571 + messageId, 2572 + emoji, 2573 + currentUserDid, 2574 + ); 2575 + assertEquals(dataStore.getMessage(messageId), updatedMessage); 2576 + assertEquals(patchStore._getMessagePatches(messageId).length, 0); 2577 + }); 2578 + 2579 + it("should clear the convo's lastReaction when the convo is cached", async () => { 2580 + const { mutations, dataStore } = setup({ 2581 + convo: { 2582 + id: convoId, 2583 + lastReaction: { existing: true }, 2584 + }, 2585 + }); 2586 + await mutations.removeMessageReaction( 2587 + convoId, 2588 + messageId, 2589 + emoji, 2590 + currentUserDid, 2591 + ); 2592 + assertEquals(dataStore.getConvo(convoId).lastReaction, null); 2593 + }); 2594 + }); 2595 + 2596 + t.describe("sendShowLessInteraction", (it) => { 2597 + const postURI = "at://did:plc:author/app.bsky.feed.post/1"; 2598 + const feedContext = "ctx"; 2599 + const feedProxyUrl = "https://feed.example/xrpc"; 2600 + 2601 + it("should append the interaction to the dataStore (empty list branch)", async () => { 2602 + const dataStore = new DataStore(); 2603 + const patchStore = new PatchStore(); 2604 + const mockPreferencesProvider = { 2605 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2606 + }; 2607 + let sendArgs = null; 2608 + const mutations = new Mutations( 2609 + { 2610 + sendInteractions: async (interactions, proxy) => { 2611 + sendArgs = { interactions, proxy }; 2612 + }, 2613 + }, 2614 + dataStore, 2615 + patchStore, 2616 + mockPreferencesProvider, 2617 + ); 2618 + 2619 + await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 2620 + 2621 + const stored = dataStore.getShowLessInteractions(); 2622 + assertEquals(stored.length, 1); 2623 + assertEquals(stored[0].item, postURI); 2624 + assertEquals(stored[0].event, "app.bsky.feed.defs#requestLess"); 2625 + assertEquals(stored[0].feedContext, feedContext); 2626 + assertEquals(sendArgs.interactions.length, 1); 2627 + assertEquals(sendArgs.interactions[0].item, postURI); 2628 + assertEquals(sendArgs.proxy, feedProxyUrl); 2629 + }); 2630 + 2631 + it("should append to an existing list (non-empty branch)", async () => { 2632 + const dataStore = new DataStore(); 2633 + const patchStore = new PatchStore(); 2634 + const mockPreferencesProvider = { 2635 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2636 + }; 2637 + dataStore.addShowLessInteraction({ item: "existing", event: "x" }); 2638 + const mutations = new Mutations( 2639 + { sendInteractions: async () => {} }, 2640 + dataStore, 2641 + patchStore, 2642 + mockPreferencesProvider, 2643 + ); 2644 + 2645 + await mutations.sendShowLessInteraction(postURI, feedContext, feedProxyUrl); 2646 + 2647 + const stored = dataStore.getShowLessInteractions(); 2648 + assertEquals(stored.length, 2); 2649 + assertEquals(stored[1].item, postURI); 2650 + }); 2651 + }); 2652 + 2653 + t.describe("sendShowMoreInteraction", (it) => { 2654 + const postURI = "at://did:plc:author/app.bsky.feed.post/1"; 2655 + const feedContext = "ctx"; 2656 + const feedProxyUrl = "https://feed.example/xrpc"; 2657 + 2658 + it("should append the interaction to the dataStore (empty list branch)", async () => { 2659 + const dataStore = new DataStore(); 2660 + const patchStore = new PatchStore(); 2661 + const mockPreferencesProvider = { 2662 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2663 + }; 2664 + let sendArgs = null; 2665 + const mutations = new Mutations( 2666 + { 2667 + sendInteractions: async (interactions, proxy) => { 2668 + sendArgs = { interactions, proxy }; 2669 + }, 2670 + }, 2671 + dataStore, 2672 + patchStore, 2673 + mockPreferencesProvider, 2674 + ); 2675 + 2676 + await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 2677 + 2678 + const stored = dataStore.getShowMoreInteractions(); 2679 + assertEquals(stored.length, 1); 2680 + assertEquals(stored[0].item, postURI); 2681 + assertEquals(stored[0].event, "app.bsky.feed.defs#requestMore"); 2682 + assertEquals(stored[0].feedContext, feedContext); 2683 + assertEquals(sendArgs.interactions[0].item, postURI); 2684 + assertEquals(sendArgs.proxy, feedProxyUrl); 2685 + }); 2686 + 2687 + it("should append to an existing list (non-empty branch)", async () => { 2688 + const dataStore = new DataStore(); 2689 + const patchStore = new PatchStore(); 2690 + const mockPreferencesProvider = { 2691 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 2692 + }; 2693 + dataStore.addShowMoreInteraction({ item: "existing", event: "x" }); 2694 + const mutations = new Mutations( 2695 + { sendInteractions: async () => {} }, 2696 + dataStore, 2697 + patchStore, 2698 + mockPreferencesProvider, 2699 + ); 2700 + 2701 + await mutations.sendShowMoreInteraction(postURI, feedContext, feedProxyUrl); 2702 + 2703 + const stored = dataStore.getShowMoreInteractions(); 2704 + assertEquals(stored.length, 2); 2705 + assertEquals(stored[1].item, postURI); 2706 + }); 2707 + }); 2708 + 1368 2709 await t.run();
+1302 -1
tests/unit/specs/dataLayer/requests.test.js
··· 1 1 import { TestSuite } from "../../testSuite.js"; 2 - import { assertEquals } from "../../testHelpers.js"; 2 + import { assert, assertEquals } from "../../testHelpers.js"; 3 3 import { Requests } from "/js/dataLayer/requests.js"; 4 4 import { DataStore } from "/js/dataLayer/dataStore.js"; 5 5 import { Preferences } from "/js/preferences.js"; 6 + import { ApiError } from "/js/api.js"; 6 7 7 8 const t = new TestSuite("Requests"); 8 9 ··· 491 492 492 493 await requests.loadMutedProfiles({ cursor: "abc" }); 493 494 assertEquals(capturedCursor, "abc"); 495 + }); 496 + }); 497 + 498 + function makeRequests(api, dataStore = new DataStore(), preferences) { 499 + const provider = { 500 + requirePreferences: () => 501 + preferences ?? Preferences.createLoggedOutPreferences(), 502 + }; 503 + return createRequests(api, dataStore, provider); 504 + } 505 + 506 + t.describe("loadBlockedProfiles", (it) => { 507 + it("should store blocked profiles on first load", async () => { 508 + const res = { 509 + blocks: [{ did: "did:plc:a" }, { did: "did:plc:b" }], 510 + cursor: "next", 511 + }; 512 + const mockApi = { getBlocks: async () => res }; 513 + const dataStore = new DataStore(); 514 + const requests = makeRequests(mockApi, dataStore); 515 + 516 + await requests.loadBlockedProfiles(); 517 + 518 + assertEquals(dataStore.getBlockedProfiles(), res); 519 + }); 520 + 521 + it("should append paginated blocked profiles when cursor is provided", async () => { 522 + const dataStore = new DataStore(); 523 + dataStore.setBlockedProfiles({ 524 + blocks: [{ did: "did:plc:a" }], 525 + cursor: "page2", 526 + }); 527 + 528 + const mockApi = { 529 + getBlocks: async () => ({ 530 + blocks: [{ did: "did:plc:b" }], 531 + cursor: undefined, 532 + }), 533 + }; 534 + const requests = makeRequests(mockApi, dataStore); 535 + 536 + await requests.loadBlockedProfiles({ cursor: "page2" }); 537 + 538 + const stored = dataStore.getBlockedProfiles(); 539 + assertEquals(stored.blocks.length, 2); 540 + assertEquals(stored.blocks[0].did, "did:plc:a"); 541 + assertEquals(stored.blocks[1].did, "did:plc:b"); 542 + }); 543 + 544 + it("should pass cursor through to the api", async () => { 545 + let capturedCursor; 546 + const mockApi = { 547 + getBlocks: async ({ cursor }) => { 548 + capturedCursor = cursor; 549 + return { blocks: [], cursor: undefined }; 550 + }, 551 + }; 552 + const dataStore = new DataStore(); 553 + dataStore.setBlockedProfiles({ blocks: [], cursor: "abc" }); 554 + const requests = makeRequests(mockApi, dataStore); 555 + 556 + await requests.loadBlockedProfiles({ cursor: "abc" }); 557 + assertEquals(capturedCursor, "abc"); 558 + }); 559 + }); 560 + 561 + t.describe("loadNextAuthorFeedPage", (it) => { 562 + const did = "did:plc:author"; 563 + 564 + it("should call getAuthorFeed with posts filter for posts feedType", async () => { 565 + let capturedParams; 566 + const mockApi = { 567 + getAuthorFeed: async (calledDid, params) => { 568 + capturedParams = { did: calledDid, ...params }; 569 + return { feed: [{ post: { uri: "p1" } }], cursor: "c1" }; 570 + }, 571 + }; 572 + const dataStore = new DataStore(); 573 + const requests = makeRequests(mockApi, dataStore); 574 + 575 + await requests.loadNextAuthorFeedPage(did, "posts"); 576 + 577 + assertEquals(capturedParams.did, did); 578 + assertEquals(capturedParams.filter, "posts_and_author_threads"); 579 + assertEquals(capturedParams.includePins, true); 580 + assertEquals(capturedParams.cursor, ""); 581 + assertEquals(dataStore.getAuthorFeed(`${did}-posts`).feed.length, 1); 582 + }); 583 + 584 + it("should use posts_with_replies filter for replies feedType", async () => { 585 + let capturedParams; 586 + const mockApi = { 587 + getAuthorFeed: async (_did, params) => { 588 + capturedParams = params; 589 + return { feed: [], cursor: null }; 590 + }, 591 + }; 592 + const requests = makeRequests(mockApi); 593 + 594 + await requests.loadNextAuthorFeedPage(did, "replies"); 595 + 596 + assertEquals(capturedParams.filter, "posts_with_replies"); 597 + assertEquals(capturedParams.includePins, false); 598 + }); 599 + 600 + it("should use posts_with_media filter for media feedType", async () => { 601 + let capturedParams; 602 + const mockApi = { 603 + getAuthorFeed: async (_did, params) => { 604 + capturedParams = params; 605 + return { feed: [], cursor: null }; 606 + }, 607 + }; 608 + const requests = makeRequests(mockApi); 609 + 610 + await requests.loadNextAuthorFeedPage(did, "media"); 611 + 612 + assertEquals(capturedParams.filter, "posts_with_media"); 613 + assertEquals(capturedParams.includePins, false); 614 + }); 615 + 616 + it("should call getActorLikes for likes feedType", async () => { 617 + let actorLikesCalled = false; 618 + let authorFeedCalled = false; 619 + const mockApi = { 620 + getActorLikes: async () => { 621 + actorLikesCalled = true; 622 + return { feed: [], cursor: null }; 623 + }, 624 + getAuthorFeed: async () => { 625 + authorFeedCalled = true; 626 + return { feed: [], cursor: null }; 627 + }, 628 + }; 629 + const requests = makeRequests(mockApi); 630 + 631 + await requests.loadNextAuthorFeedPage(did, "likes"); 632 + 633 + assertEquals(actorLikesCalled, true); 634 + assertEquals(authorFeedCalled, false); 635 + }); 636 + 637 + it("should append to existing feed", async () => { 638 + const feedURI = `${did}-posts`; 639 + const dataStore = new DataStore(); 640 + dataStore.setAuthorFeed(feedURI, { 641 + feed: [{ post: { uri: "old1" } }], 642 + cursor: "c1", 643 + }); 644 + 645 + const mockApi = { 646 + getAuthorFeed: async () => ({ 647 + feed: [{ post: { uri: "new1" } }], 648 + cursor: "c2", 649 + }), 650 + }; 651 + const requests = makeRequests(mockApi, dataStore); 652 + 653 + await requests.loadNextAuthorFeedPage(did, "posts"); 654 + 655 + const stored = dataStore.getAuthorFeed(feedURI); 656 + assertEquals(stored.feed.length, 2); 657 + assertEquals(stored.feed[0].post.uri, "old1"); 658 + assertEquals(stored.feed[1].post.uri, "new1"); 659 + assertEquals(stored.cursor, "c2"); 660 + }); 661 + 662 + it("should reset cursor and replace feed on reload", async () => { 663 + const feedURI = `${did}-posts`; 664 + const dataStore = new DataStore(); 665 + dataStore.setAuthorFeed(feedURI, { 666 + feed: [{ post: { uri: "old1" } }], 667 + cursor: "c1", 668 + }); 669 + 670 + let capturedCursor; 671 + const mockApi = { 672 + getAuthorFeed: async (_did, params) => { 673 + capturedCursor = params.cursor; 674 + return { feed: [{ post: { uri: "new1" } }], cursor: "c2" }; 675 + }, 676 + }; 677 + const requests = makeRequests(mockApi, dataStore); 678 + 679 + await requests.loadNextAuthorFeedPage(did, "posts", { reload: true }); 680 + 681 + assertEquals(capturedCursor, ""); 682 + const stored = dataStore.getAuthorFeed(feedURI); 683 + assertEquals(stored.feed.length, 1); 684 + assertEquals(stored.feed[0].post.uri, "new1"); 685 + }); 686 + 687 + it("should throw on unknown feed type", async () => { 688 + const mockApi = { getAuthorFeed: async () => ({ feed: [], cursor: null }) }; 689 + const requests = makeRequests(mockApi); 690 + 691 + let caught = null; 692 + try { 693 + await requests.loadNextAuthorFeedPage(did, "bogus"); 694 + } catch (error) { 695 + caught = error; 696 + } 697 + assert(caught !== null, "expected error for unknown feed type"); 698 + }); 699 + }); 700 + 701 + t.describe("loadPostSearch", (it) => { 702 + it("should clear results when query is empty", async () => { 703 + const dataStore = new DataStore(); 704 + dataStore.setPostSearchResults({ posts: [{ uri: "p1" }], cursor: "c1" }); 705 + const mockApi = { searchPosts: async () => ({ posts: [], cursor: null }) }; 706 + const requests = makeRequests(mockApi, dataStore); 707 + 708 + await requests.loadPostSearch(""); 709 + 710 + assertEquals(dataStore.getPostSearchResults(), null); 711 + }); 712 + 713 + it("should store results from a fresh search", async () => { 714 + const mockApi = { 715 + searchPosts: async () => ({ 716 + posts: [{ uri: "p1", record: {} }], 717 + cursor: "next", 718 + }), 719 + }; 720 + const dataStore = new DataStore(); 721 + const requests = makeRequests(mockApi, dataStore); 722 + 723 + await requests.loadPostSearch("hello"); 724 + 725 + const stored = dataStore.getPostSearchResults(); 726 + assertEquals(stored.posts.length, 1); 727 + assertEquals(stored.cursor, "next"); 728 + }); 729 + 730 + it("should discard stale responses based on requestTime guard", async () => { 731 + const dataStore = new DataStore(); 732 + let resolveFirst; 733 + const firstPromise = new Promise((resolve) => { 734 + resolveFirst = resolve; 735 + }); 736 + let callIndex = 0; 737 + const mockApi = { 738 + searchPosts: async () => { 739 + callIndex += 1; 740 + if (callIndex === 1) { 741 + await firstPromise; 742 + return { posts: [{ uri: "stale", record: {} }], cursor: "stale" }; 743 + } 744 + return { posts: [{ uri: "fresh", record: {} }], cursor: "fresh" }; 745 + }, 746 + }; 747 + const requests = makeRequests(mockApi, dataStore); 748 + 749 + const firstCall = requests.loadPostSearch("query"); 750 + await new Promise((resolve) => setTimeout(resolve, 5)); 751 + await requests.loadPostSearch("query"); 752 + resolveFirst(); 753 + await firstCall; 754 + 755 + const stored = dataStore.getPostSearchResults(); 756 + assertEquals(stored.posts[0].uri, "fresh"); 757 + assertEquals(stored.cursor, "fresh"); 758 + }); 759 + 760 + it("should append when cursor is provided and existing results present", async () => { 761 + const dataStore = new DataStore(); 762 + dataStore.setPostSearchResults({ 763 + posts: [{ uri: "p1", record: {} }], 764 + cursor: "c1", 765 + }); 766 + const mockApi = { 767 + searchPosts: async () => ({ 768 + posts: [{ uri: "p2", record: {} }], 769 + cursor: "c2", 770 + }), 771 + }; 772 + const requests = makeRequests(mockApi, dataStore); 773 + 774 + await requests.loadPostSearch("hello", { cursor: "c1" }); 775 + 776 + const stored = dataStore.getPostSearchResults(); 777 + assertEquals(stored.posts.length, 2); 778 + assertEquals(stored.posts[1].uri, "p2"); 779 + assertEquals(stored.cursor, "c2"); 780 + }); 781 + }); 782 + 783 + t.describe("loadProfileSearch", (it) => { 784 + it("should clear results when query is empty", async () => { 785 + const dataStore = new DataStore(); 786 + dataStore.setProfileSearchResults({ actors: [{ did: "x" }], cursor: "c" }); 787 + const mockApi = { 788 + searchProfiles: async () => ({ actors: [], cursor: null }), 789 + }; 790 + const requests = makeRequests(mockApi, dataStore); 791 + 792 + await requests.loadProfileSearch(""); 793 + 794 + assertEquals(dataStore.getProfileSearchResults(), null); 795 + }); 796 + 797 + it("should store actors from a fresh search", async () => { 798 + const mockApi = { 799 + searchProfiles: async () => ({ 800 + actors: [{ did: "did:plc:a" }], 801 + cursor: "next", 802 + }), 803 + }; 804 + const dataStore = new DataStore(); 805 + const requests = makeRequests(mockApi, dataStore); 806 + 807 + await requests.loadProfileSearch("alice"); 808 + 809 + const stored = dataStore.getProfileSearchResults(); 810 + assertEquals(stored.actors.length, 1); 811 + assertEquals(stored.cursor, "next"); 812 + }); 813 + 814 + it("should discard stale responses", async () => { 815 + const dataStore = new DataStore(); 816 + let resolveFirst; 817 + const firstPromise = new Promise((resolve) => { 818 + resolveFirst = resolve; 819 + }); 820 + let callIndex = 0; 821 + const mockApi = { 822 + searchProfiles: async () => { 823 + callIndex += 1; 824 + if (callIndex === 1) { 825 + await firstPromise; 826 + return { actors: [{ did: "stale" }], cursor: "stale" }; 827 + } 828 + return { actors: [{ did: "fresh" }], cursor: "fresh" }; 829 + }, 830 + }; 831 + const requests = makeRequests(mockApi, dataStore); 832 + 833 + const firstCall = requests.loadProfileSearch("query"); 834 + await new Promise((resolve) => setTimeout(resolve, 5)); 835 + await requests.loadProfileSearch("query"); 836 + resolveFirst(); 837 + await firstCall; 838 + 839 + const stored = dataStore.getProfileSearchResults(); 840 + assertEquals(stored.actors[0].did, "fresh"); 841 + }); 842 + 843 + it("should append when cursor is provided", async () => { 844 + const dataStore = new DataStore(); 845 + dataStore.setProfileSearchResults({ 846 + actors: [{ did: "did:plc:a" }], 847 + cursor: "c1", 848 + }); 849 + const mockApi = { 850 + searchProfiles: async () => ({ 851 + actors: [{ did: "did:plc:b" }], 852 + cursor: "c2", 853 + }), 854 + }; 855 + const requests = makeRequests(mockApi, dataStore); 856 + 857 + await requests.loadProfileSearch("query", { cursor: "c1" }); 858 + 859 + const stored = dataStore.getProfileSearchResults(); 860 + assertEquals(stored.actors.length, 2); 861 + assertEquals(stored.cursor, "c2"); 862 + }); 863 + }); 864 + 865 + t.describe("loadFeedSearch", (it) => { 866 + it("should clear results when query is empty", async () => { 867 + const dataStore = new DataStore(); 868 + dataStore.setFeedSearchResults({ feeds: [{ uri: "f1" }], cursor: "c" }); 869 + const mockApi = { 870 + searchFeedGenerators: async () => ({ feeds: [], cursor: null }), 871 + }; 872 + const requests = makeRequests(mockApi, dataStore); 873 + 874 + await requests.loadFeedSearch(""); 875 + 876 + assertEquals(dataStore.getFeedSearchResults(), null); 877 + }); 878 + 879 + it("should store feeds and cache feed generators", async () => { 880 + const dataStore = new DataStore(); 881 + const mockApi = { 882 + searchFeedGenerators: async () => ({ 883 + feeds: [{ uri: "f1", displayName: "Feed One" }], 884 + cursor: "next", 885 + }), 886 + }; 887 + const requests = makeRequests(mockApi, dataStore); 888 + 889 + await requests.loadFeedSearch("news"); 890 + 891 + const stored = dataStore.getFeedSearchResults(); 892 + assertEquals(stored.feeds.length, 1); 893 + assertEquals(dataStore.getFeedGenerator("f1").displayName, "Feed One"); 894 + }); 895 + 896 + it("should discard stale responses", async () => { 897 + const dataStore = new DataStore(); 898 + let resolveFirst; 899 + const firstPromise = new Promise((resolve) => { 900 + resolveFirst = resolve; 901 + }); 902 + let callIndex = 0; 903 + const mockApi = { 904 + searchFeedGenerators: async () => { 905 + callIndex += 1; 906 + if (callIndex === 1) { 907 + await firstPromise; 908 + return { feeds: [{ uri: "stale" }], cursor: "stale" }; 909 + } 910 + return { feeds: [{ uri: "fresh" }], cursor: "fresh" }; 911 + }, 912 + }; 913 + const requests = makeRequests(mockApi, dataStore); 914 + 915 + const firstCall = requests.loadFeedSearch("query"); 916 + await new Promise((resolve) => setTimeout(resolve, 5)); 917 + await requests.loadFeedSearch("query"); 918 + resolveFirst(); 919 + await firstCall; 920 + 921 + const stored = dataStore.getFeedSearchResults(); 922 + assertEquals(stored.feeds[0].uri, "fresh"); 923 + }); 924 + 925 + it("should append when cursor is provided", async () => { 926 + const dataStore = new DataStore(); 927 + dataStore.setFeedSearchResults({ 928 + feeds: [{ uri: "f1" }], 929 + cursor: "c1", 930 + }); 931 + const mockApi = { 932 + searchFeedGenerators: async () => ({ 933 + feeds: [{ uri: "f2" }], 934 + cursor: "c2", 935 + }), 936 + }; 937 + const requests = makeRequests(mockApi, dataStore); 938 + 939 + await requests.loadFeedSearch("query", { cursor: "c1" }); 940 + 941 + const stored = dataStore.getFeedSearchResults(); 942 + assertEquals(stored.feeds.length, 2); 943 + assertEquals(stored.cursor, "c2"); 944 + }); 945 + }); 946 + 947 + t.describe("loadNotifications", (it) => { 948 + it("should set notifications and cursor on first load", async () => { 949 + const dataStore = new DataStore(); 950 + const mockApi = { 951 + getNotifications: async () => ({ 952 + notifications: [{ reason: "like", uri: "n1" }], 953 + cursor: "next", 954 + }), 955 + getPosts: async () => [], 956 + }; 957 + const requests = makeRequests(mockApi, dataStore); 958 + 959 + await requests.loadNotifications(); 960 + 961 + assertEquals(dataStore.getNotifications().length, 1); 962 + assertEquals(dataStore.getNotificationCursor(), "next"); 963 + }); 964 + 965 + it("should append when cursor matches previous", async () => { 966 + const dataStore = new DataStore(); 967 + dataStore.setNotifications([{ reason: "like", uri: "n1" }]); 968 + dataStore.setNotificationCursor("page2"); 969 + 970 + let capturedCursor; 971 + const mockApi = { 972 + getNotifications: async ({ cursor }) => { 973 + capturedCursor = cursor; 974 + return { 975 + notifications: [{ reason: "follow", uri: "n2" }], 976 + cursor: "page3", 977 + }; 978 + }, 979 + getPosts: async () => [], 980 + }; 981 + const requests = makeRequests(mockApi, dataStore); 982 + 983 + await requests.loadNotifications(); 984 + 985 + assertEquals(capturedCursor, "page2"); 986 + assertEquals(dataStore.getNotifications().length, 2); 987 + assertEquals(dataStore.getNotificationCursor(), "page3"); 988 + }); 989 + 990 + it("should reset on reload", async () => { 991 + const dataStore = new DataStore(); 992 + dataStore.setNotifications([{ reason: "like", uri: "n1" }]); 993 + dataStore.setNotificationCursor("page2"); 994 + 995 + let capturedCursor; 996 + const mockApi = { 997 + getNotifications: async ({ cursor }) => { 998 + capturedCursor = cursor; 999 + return { 1000 + notifications: [{ reason: "follow", uri: "n2" }], 1001 + cursor: "fresh", 1002 + }; 1003 + }, 1004 + getPosts: async () => [], 1005 + }; 1006 + const requests = makeRequests(mockApi, dataStore); 1007 + 1008 + await requests.loadNotifications({ reload: true }); 1009 + 1010 + assertEquals(capturedCursor, ""); 1011 + const stored = dataStore.getNotifications(); 1012 + assertEquals(stored.length, 1); 1013 + assertEquals(stored[0].uri, "n2"); 1014 + assertEquals(dataStore.getNotificationCursor(), "fresh"); 1015 + }); 1016 + }); 1017 + 1018 + t.describe("loadMentionNotifications", (it) => { 1019 + it("should request only mention reasons and store results", async () => { 1020 + const dataStore = new DataStore(); 1021 + let capturedReasons; 1022 + const mockApi = { 1023 + getNotifications: async ({ reasons }) => { 1024 + capturedReasons = reasons; 1025 + return { 1026 + notifications: [{ reason: "mention", uri: "n1" }], 1027 + cursor: "next", 1028 + }; 1029 + }, 1030 + getPosts: async () => [], 1031 + }; 1032 + const requests = makeRequests(mockApi, dataStore); 1033 + 1034 + await requests.loadMentionNotifications(); 1035 + 1036 + assertEquals(capturedReasons, ["mention", "reply", "quote"]); 1037 + assertEquals(dataStore.getMentionNotifications().length, 1); 1038 + assertEquals(dataStore.getMentionNotificationCursor(), "next"); 1039 + }); 1040 + 1041 + it("should append when cursor matches previous", async () => { 1042 + const dataStore = new DataStore(); 1043 + dataStore.setMentionNotifications([{ reason: "mention", uri: "n1" }]); 1044 + dataStore.setMentionNotificationCursor("page2"); 1045 + 1046 + const mockApi = { 1047 + getNotifications: async () => ({ 1048 + notifications: [{ reason: "reply", uri: "n2" }], 1049 + cursor: "page3", 1050 + }), 1051 + getPosts: async () => [], 1052 + }; 1053 + const requests = makeRequests(mockApi, dataStore); 1054 + 1055 + await requests.loadMentionNotifications(); 1056 + 1057 + assertEquals(dataStore.getMentionNotifications().length, 2); 1058 + assertEquals(dataStore.getMentionNotificationCursor(), "page3"); 1059 + }); 1060 + 1061 + it("should reset on reload", async () => { 1062 + const dataStore = new DataStore(); 1063 + dataStore.setMentionNotifications([{ reason: "mention", uri: "n1" }]); 1064 + dataStore.setMentionNotificationCursor("page2"); 1065 + 1066 + const mockApi = { 1067 + getNotifications: async () => ({ 1068 + notifications: [{ reason: "quote", uri: "n2" }], 1069 + cursor: "fresh", 1070 + }), 1071 + getPosts: async () => [], 1072 + }; 1073 + const requests = makeRequests(mockApi, dataStore); 1074 + 1075 + await requests.loadMentionNotifications({ reload: true }); 1076 + 1077 + const stored = dataStore.getMentionNotifications(); 1078 + assertEquals(stored.length, 1); 1079 + assertEquals(stored[0].uri, "n2"); 1080 + }); 1081 + }); 1082 + 1083 + t.describe("loadBookmarks", (it) => { 1084 + it("should set bookmarks on first load", async () => { 1085 + const dataStore = new DataStore(); 1086 + const mockApi = { 1087 + getBookmarks: async () => ({ 1088 + bookmarks: [{ item: { uri: "post1", record: {} } }], 1089 + cursor: "next", 1090 + }), 1091 + getPosts: async () => [], 1092 + }; 1093 + const requests = makeRequests(mockApi, dataStore); 1094 + 1095 + await requests.loadBookmarks(); 1096 + 1097 + const stored = dataStore.getBookmarks(); 1098 + assertEquals(stored.feed.length, 1); 1099 + assertEquals(stored.feed[0].post.uri, "post1"); 1100 + assertEquals(stored.cursor, "next"); 1101 + }); 1102 + 1103 + it("should append on subsequent loads", async () => { 1104 + const dataStore = new DataStore(); 1105 + dataStore.setBookmarks({ 1106 + feed: [{ post: { uri: "post1" } }], 1107 + cursor: "c1", 1108 + }); 1109 + const mockApi = { 1110 + getBookmarks: async () => ({ 1111 + bookmarks: [{ item: { uri: "post2", record: {} } }], 1112 + cursor: "c2", 1113 + }), 1114 + getPosts: async () => [], 1115 + }; 1116 + const requests = makeRequests(mockApi, dataStore); 1117 + 1118 + await requests.loadBookmarks(); 1119 + 1120 + const stored = dataStore.getBookmarks(); 1121 + assertEquals(stored.feed.length, 2); 1122 + assertEquals(stored.feed[1].post.uri, "post2"); 1123 + assertEquals(stored.cursor, "c2"); 1124 + }); 1125 + 1126 + it("should reset on reload", async () => { 1127 + const dataStore = new DataStore(); 1128 + dataStore.setBookmarks({ 1129 + feed: [{ post: { uri: "post1" } }], 1130 + cursor: "c1", 1131 + }); 1132 + 1133 + let capturedCursor; 1134 + const mockApi = { 1135 + getBookmarks: async ({ cursor }) => { 1136 + capturedCursor = cursor; 1137 + return { 1138 + bookmarks: [{ item: { uri: "post2", record: {} } }], 1139 + cursor: "fresh", 1140 + }; 1141 + }, 1142 + getPosts: async () => [], 1143 + }; 1144 + const requests = makeRequests(mockApi, dataStore); 1145 + 1146 + await requests.loadBookmarks({ reload: true }); 1147 + 1148 + assertEquals(capturedCursor, ""); 1149 + const stored = dataStore.getBookmarks(); 1150 + assertEquals(stored.feed.length, 1); 1151 + assertEquals(stored.feed[0].post.uri, "post2"); 1152 + }); 1153 + }); 1154 + 1155 + t.describe("loadProfileFollowers", (it) => { 1156 + const profileDid = "did:plc:profile"; 1157 + 1158 + it("should set followers on first load", async () => { 1159 + const dataStore = new DataStore(); 1160 + const res = { 1161 + followers: [{ did: "did:plc:a" }], 1162 + cursor: "next", 1163 + }; 1164 + const mockApi = { getFollowers: async () => res }; 1165 + const requests = makeRequests(mockApi, dataStore); 1166 + 1167 + await requests.loadProfileFollowers(profileDid); 1168 + 1169 + assertEquals(dataStore.getProfileFollowers(profileDid), res); 1170 + }); 1171 + 1172 + it("should append followers when cursor is provided", async () => { 1173 + const dataStore = new DataStore(); 1174 + dataStore.setProfileFollowers(profileDid, { 1175 + followers: [{ did: "did:plc:a" }], 1176 + cursor: "c1", 1177 + }); 1178 + const mockApi = { 1179 + getFollowers: async () => ({ 1180 + followers: [{ did: "did:plc:b" }], 1181 + cursor: "c2", 1182 + }), 1183 + }; 1184 + const requests = makeRequests(mockApi, dataStore); 1185 + 1186 + await requests.loadProfileFollowers(profileDid, { cursor: "c1" }); 1187 + 1188 + const stored = dataStore.getProfileFollowers(profileDid); 1189 + assertEquals(stored.followers.length, 2); 1190 + assertEquals(stored.cursor, "c2"); 1191 + }); 1192 + }); 1193 + 1194 + t.describe("loadProfileFollows", (it) => { 1195 + const profileDid = "did:plc:profile"; 1196 + 1197 + it("should set follows on first load", async () => { 1198 + const dataStore = new DataStore(); 1199 + const res = { follows: [{ did: "did:plc:a" }], cursor: "next" }; 1200 + const mockApi = { getFollows: async () => res }; 1201 + const requests = makeRequests(mockApi, dataStore); 1202 + 1203 + await requests.loadProfileFollows(profileDid); 1204 + 1205 + assertEquals(dataStore.getProfileFollows(profileDid), res); 1206 + }); 1207 + 1208 + it("should append follows when cursor is provided", async () => { 1209 + const dataStore = new DataStore(); 1210 + dataStore.setProfileFollows(profileDid, { 1211 + follows: [{ did: "did:plc:a" }], 1212 + cursor: "c1", 1213 + }); 1214 + const mockApi = { 1215 + getFollows: async () => ({ 1216 + follows: [{ did: "did:plc:b" }], 1217 + cursor: "c2", 1218 + }), 1219 + }; 1220 + const requests = makeRequests(mockApi, dataStore); 1221 + 1222 + await requests.loadProfileFollows(profileDid, { cursor: "c1" }); 1223 + 1224 + const stored = dataStore.getProfileFollows(profileDid); 1225 + assertEquals(stored.follows.length, 2); 1226 + assertEquals(stored.cursor, "c2"); 1227 + }); 1228 + }); 1229 + 1230 + t.describe("loadConvoList", (it) => { 1231 + it("should set convo list and cache individual convos on first load", async () => { 1232 + const dataStore = new DataStore(); 1233 + const mockApi = { 1234 + listConvos: async () => ({ 1235 + convos: [ 1236 + { id: "c1", lastMessage: null }, 1237 + { id: "c2", lastMessage: null }, 1238 + ], 1239 + cursor: "next", 1240 + }), 1241 + }; 1242 + const requests = makeRequests(mockApi, dataStore); 1243 + 1244 + await requests.loadConvoList(); 1245 + 1246 + assertEquals(dataStore.getConvoList().length, 2); 1247 + assertEquals(dataStore.getConvo("c1").id, "c1"); 1248 + assertEquals(dataStore.getConvo("c2").id, "c2"); 1249 + assertEquals(dataStore.getConvoListCursor(), "next"); 1250 + }); 1251 + 1252 + it("should append when previous cursor matches", async () => { 1253 + const dataStore = new DataStore(); 1254 + dataStore.setConvoList([{ id: "c1" }]); 1255 + dataStore.setConvoListCursor("page2"); 1256 + 1257 + const mockApi = { 1258 + listConvos: async () => ({ 1259 + convos: [{ id: "c2" }], 1260 + cursor: "page3", 1261 + }), 1262 + }; 1263 + const requests = makeRequests(mockApi, dataStore); 1264 + 1265 + await requests.loadConvoList(); 1266 + 1267 + assertEquals(dataStore.getConvoList().length, 2); 1268 + assertEquals(dataStore.getConvoListCursor(), "page3"); 1269 + }); 1270 + 1271 + it("should reset cursor and replace on reload", async () => { 1272 + const dataStore = new DataStore(); 1273 + dataStore.setConvoList([{ id: "c1" }]); 1274 + dataStore.setConvoListCursor("page2"); 1275 + 1276 + let capturedCursor; 1277 + const mockApi = { 1278 + listConvos: async ({ cursor }) => { 1279 + capturedCursor = cursor; 1280 + return { convos: [{ id: "c2" }], cursor: "fresh" }; 1281 + }, 1282 + }; 1283 + const requests = makeRequests(mockApi, dataStore); 1284 + 1285 + await requests.loadConvoList({ reload: true }); 1286 + 1287 + assertEquals(capturedCursor, ""); 1288 + const stored = dataStore.getConvoList(); 1289 + assertEquals(stored.length, 1); 1290 + assertEquals(stored[0].id, "c2"); 1291 + }); 1292 + }); 1293 + 1294 + t.describe("loadConvoMessages", (it) => { 1295 + const convoId = "convo1"; 1296 + 1297 + it("should set messages on first load", async () => { 1298 + const dataStore = new DataStore(); 1299 + const mockApi = { 1300 + getMessages: async () => ({ 1301 + messages: [{ id: "m1" }, { id: "m2" }], 1302 + cursor: null, 1303 + }), 1304 + }; 1305 + const requests = makeRequests(mockApi, dataStore); 1306 + 1307 + await requests.loadConvoMessages(convoId); 1308 + 1309 + const stored = dataStore.getConvoMessages(convoId); 1310 + assertEquals(stored.messages.length, 2); 1311 + assertEquals(dataStore.getMessage("m1").id, "m1"); 1312 + }); 1313 + 1314 + it("should append messages when prior cursor exists", async () => { 1315 + const dataStore = new DataStore(); 1316 + dataStore.setConvoMessages(convoId, { 1317 + messages: [{ id: "m1" }], 1318 + cursor: "page2", 1319 + }); 1320 + 1321 + let calls = 0; 1322 + const mockApi = { 1323 + getMessages: async () => { 1324 + calls += 1; 1325 + if (calls === 1) { 1326 + return { messages: [{ id: "m2" }], cursor: null }; 1327 + } 1328 + return { messages: [], cursor: null }; 1329 + }, 1330 + }; 1331 + const requests = makeRequests(mockApi, dataStore); 1332 + 1333 + await requests.loadConvoMessages(convoId); 1334 + 1335 + const stored = dataStore.getConvoMessages(convoId); 1336 + assertEquals(stored.messages.length, 2); 1337 + assertEquals(stored.messages[0].id, "m1"); 1338 + assertEquals(stored.messages[1].id, "m2"); 1339 + }); 1340 + 1341 + it("should null out cursor when validation second-page is empty", async () => { 1342 + const dataStore = new DataStore(); 1343 + let calls = 0; 1344 + const mockApi = { 1345 + getMessages: async () => { 1346 + calls += 1; 1347 + if (calls === 1) { 1348 + return { messages: [{ id: "m1" }], cursor: "fakecursor" }; 1349 + } 1350 + return { messages: [], cursor: null }; 1351 + }, 1352 + }; 1353 + const requests = makeRequests(mockApi, dataStore); 1354 + 1355 + await requests.loadConvoMessages(convoId); 1356 + 1357 + assertEquals(dataStore.getConvoMessages(convoId).cursor, null); 1358 + }); 1359 + 1360 + it("should reset on reload", async () => { 1361 + const dataStore = new DataStore(); 1362 + dataStore.setConvoMessages(convoId, { 1363 + messages: [{ id: "old" }], 1364 + cursor: "page2", 1365 + }); 1366 + 1367 + let capturedCursor; 1368 + const mockApi = { 1369 + getMessages: async (_id, { cursor }) => { 1370 + capturedCursor = cursor; 1371 + return { messages: [{ id: "fresh" }], cursor: null }; 1372 + }, 1373 + }; 1374 + const requests = makeRequests(mockApi, dataStore); 1375 + 1376 + await requests.loadConvoMessages(convoId, { reload: true }); 1377 + 1378 + assertEquals(capturedCursor, ""); 1379 + const stored = dataStore.getConvoMessages(convoId); 1380 + assertEquals(stored.messages.length, 1); 1381 + assertEquals(stored.messages[0].id, "fresh"); 1382 + }); 1383 + }); 1384 + 1385 + t.describe("loadPostLikes", (it) => { 1386 + const postUri = "at://did/post/1"; 1387 + 1388 + it("should set likes on first load", async () => { 1389 + const dataStore = new DataStore(); 1390 + const res = { likes: [{ actor: { did: "did:plc:a" } }], cursor: "next" }; 1391 + const mockApi = { getLikes: async () => res }; 1392 + const requests = makeRequests(mockApi, dataStore); 1393 + 1394 + await requests.loadPostLikes(postUri); 1395 + 1396 + assertEquals(dataStore.getPostLikes(postUri), res); 1397 + }); 1398 + 1399 + it("should append likes when cursor is provided", async () => { 1400 + const dataStore = new DataStore(); 1401 + dataStore.setPostLikes(postUri, { 1402 + likes: [{ actor: { did: "did:plc:a" } }], 1403 + cursor: "c1", 1404 + }); 1405 + const mockApi = { 1406 + getLikes: async () => ({ 1407 + likes: [{ actor: { did: "did:plc:b" } }], 1408 + cursor: "c2", 1409 + }), 1410 + }; 1411 + const requests = makeRequests(mockApi, dataStore); 1412 + 1413 + await requests.loadPostLikes(postUri, { cursor: "c1" }); 1414 + 1415 + const stored = dataStore.getPostLikes(postUri); 1416 + assertEquals(stored.likes.length, 2); 1417 + assertEquals(stored.cursor, "c2"); 1418 + }); 1419 + }); 1420 + 1421 + t.describe("loadPostQuotes", (it) => { 1422 + const postUri = "at://did/post/1"; 1423 + 1424 + it("should set quotes on first load", async () => { 1425 + const dataStore = new DataStore(); 1426 + const mockApi = { 1427 + getQuotes: async () => ({ 1428 + posts: [{ uri: "q1", record: {} }], 1429 + cursor: "next", 1430 + }), 1431 + getPosts: async () => [], 1432 + }; 1433 + const requests = makeRequests(mockApi, dataStore); 1434 + 1435 + await requests.loadPostQuotes(postUri); 1436 + 1437 + const stored = dataStore.getPostQuotes(postUri); 1438 + assertEquals(stored.posts.length, 1); 1439 + assertEquals(stored.cursor, "next"); 1440 + }); 1441 + 1442 + it("should append quotes when cursor is provided", async () => { 1443 + const dataStore = new DataStore(); 1444 + dataStore.setPostQuotes(postUri, { 1445 + posts: [{ uri: "q1", record: {} }], 1446 + cursor: "c1", 1447 + }); 1448 + const mockApi = { 1449 + getQuotes: async () => ({ 1450 + posts: [{ uri: "q2", record: {} }], 1451 + cursor: "c2", 1452 + }), 1453 + getPosts: async () => [], 1454 + }; 1455 + const requests = makeRequests(mockApi, dataStore); 1456 + 1457 + await requests.loadPostQuotes(postUri, { cursor: "c1" }); 1458 + 1459 + const stored = dataStore.getPostQuotes(postUri); 1460 + assertEquals(stored.posts.length, 2); 1461 + assertEquals(stored.cursor, "c2"); 1462 + }); 1463 + }); 1464 + 1465 + t.describe("loadPostReposts", (it) => { 1466 + const postUri = "at://did/post/1"; 1467 + 1468 + it("should set reposts on first load", async () => { 1469 + const dataStore = new DataStore(); 1470 + const mockApi = { 1471 + getRepostedBy: async () => ({ 1472 + repostedBy: [{ did: "did:plc:a" }], 1473 + cursor: "next", 1474 + }), 1475 + }; 1476 + const requests = makeRequests(mockApi, dataStore); 1477 + 1478 + await requests.loadPostReposts(postUri); 1479 + 1480 + const stored = dataStore.getPostReposts(postUri); 1481 + assertEquals(stored.reposts.length, 1); 1482 + assertEquals(stored.cursor, "next"); 1483 + }); 1484 + 1485 + it("should append reposts when cursor is provided", async () => { 1486 + const dataStore = new DataStore(); 1487 + dataStore.setPostReposts(postUri, { 1488 + reposts: [{ did: "did:plc:a" }], 1489 + cursor: "c1", 1490 + }); 1491 + const mockApi = { 1492 + getRepostedBy: async () => ({ 1493 + repostedBy: [{ did: "did:plc:b" }], 1494 + cursor: "c2", 1495 + }), 1496 + }; 1497 + const requests = makeRequests(mockApi, dataStore); 1498 + 1499 + await requests.loadPostReposts(postUri, { cursor: "c1" }); 1500 + 1501 + const stored = dataStore.getPostReposts(postUri); 1502 + assertEquals(stored.reposts.length, 2); 1503 + assertEquals(stored.cursor, "c2"); 1504 + }); 1505 + }); 1506 + 1507 + t.describe("loadActorFeeds", (it) => { 1508 + const did = "did:plc:author"; 1509 + 1510 + it("should set actor feeds and cache feed generators on first load", async () => { 1511 + const dataStore = new DataStore(); 1512 + const mockApi = { 1513 + getActorFeeds: async () => ({ 1514 + feeds: [{ uri: "f1", displayName: "F1" }], 1515 + cursor: "next", 1516 + }), 1517 + }; 1518 + const requests = makeRequests(mockApi, dataStore); 1519 + 1520 + await requests.loadActorFeeds(did); 1521 + 1522 + const stored = dataStore.getActorFeeds(did); 1523 + assertEquals(stored.feeds.length, 1); 1524 + assertEquals(stored.cursor, "next"); 1525 + assertEquals(dataStore.getFeedGenerator("f1").displayName, "F1"); 1526 + }); 1527 + 1528 + it("should append on subsequent calls when cursor remains", async () => { 1529 + const dataStore = new DataStore(); 1530 + dataStore.setActorFeeds(did, { 1531 + feeds: [{ uri: "f1" }], 1532 + cursor: "c1", 1533 + }); 1534 + const mockApi = { 1535 + getActorFeeds: async () => ({ 1536 + feeds: [{ uri: "f2" }], 1537 + cursor: null, 1538 + }), 1539 + }; 1540 + const requests = makeRequests(mockApi, dataStore); 1541 + 1542 + await requests.loadActorFeeds(did); 1543 + 1544 + const stored = dataStore.getActorFeeds(did); 1545 + assertEquals(stored.feeds.length, 2); 1546 + assertEquals(stored.cursor, null); 1547 + }); 1548 + 1549 + it("should short-circuit when there is no remaining cursor", async () => { 1550 + const dataStore = new DataStore(); 1551 + dataStore.setActorFeeds(did, { 1552 + feeds: [{ uri: "f1" }], 1553 + cursor: null, 1554 + }); 1555 + let called = false; 1556 + const mockApi = { 1557 + getActorFeeds: async () => { 1558 + called = true; 1559 + return { feeds: [], cursor: null }; 1560 + }, 1561 + }; 1562 + const requests = makeRequests(mockApi, dataStore); 1563 + 1564 + await requests.loadActorFeeds(did); 1565 + 1566 + assertEquals(called, false); 1567 + }); 1568 + 1569 + it("should reset on reload", async () => { 1570 + const dataStore = new DataStore(); 1571 + dataStore.setActorFeeds(did, { 1572 + feeds: [{ uri: "f1" }], 1573 + cursor: null, 1574 + }); 1575 + 1576 + let capturedCursor; 1577 + const mockApi = { 1578 + getActorFeeds: async (_did, { cursor }) => { 1579 + capturedCursor = cursor; 1580 + return { feeds: [{ uri: "f2" }], cursor: "next" }; 1581 + }, 1582 + }; 1583 + const requests = makeRequests(mockApi, dataStore); 1584 + 1585 + await requests.loadActorFeeds(did, { reload: true }); 1586 + 1587 + assertEquals(capturedCursor, ""); 1588 + const stored = dataStore.getActorFeeds(did); 1589 + assertEquals(stored.feeds.length, 1); 1590 + assertEquals(stored.feeds[0].uri, "f2"); 1591 + }); 1592 + }); 1593 + 1594 + t.describe("loadHashtagFeed", (it) => { 1595 + it("should store hashtag feed posts on first load", async () => { 1596 + const dataStore = new DataStore(); 1597 + const mockApi = { 1598 + searchPosts: async () => ({ 1599 + posts: [{ uri: "p1", record: {} }], 1600 + cursor: "next", 1601 + }), 1602 + getPosts: async () => [], 1603 + }; 1604 + const requests = makeRequests(mockApi, dataStore); 1605 + 1606 + await requests.loadHashtagFeed("foo", "top"); 1607 + 1608 + const stored = dataStore.getHashtagFeed("foo-top"); 1609 + assertEquals(stored.feed.length, 1); 1610 + assertEquals(stored.feed[0].post.uri, "p1"); 1611 + assertEquals(stored.cursor, "next"); 1612 + }); 1613 + 1614 + it("should append on subsequent loads", async () => { 1615 + const dataStore = new DataStore(); 1616 + dataStore.setHashtagFeed("foo-top", { 1617 + feed: [{ post: { uri: "p1" } }], 1618 + cursor: "c1", 1619 + }); 1620 + const mockApi = { 1621 + searchPosts: async () => ({ 1622 + posts: [{ uri: "p2", record: {} }], 1623 + cursor: "c2", 1624 + }), 1625 + getPosts: async () => [], 1626 + }; 1627 + const requests = makeRequests(mockApi, dataStore); 1628 + 1629 + await requests.loadHashtagFeed("foo", "top"); 1630 + 1631 + const stored = dataStore.getHashtagFeed("foo-top"); 1632 + assertEquals(stored.feed.length, 2); 1633 + assertEquals(stored.feed[1].post.uri, "p2"); 1634 + }); 1635 + 1636 + it("should reset on reload", async () => { 1637 + const dataStore = new DataStore(); 1638 + dataStore.setHashtagFeed("foo-top", { 1639 + feed: [{ post: { uri: "p1" } }], 1640 + cursor: "c1", 1641 + }); 1642 + 1643 + let capturedCursor; 1644 + const mockApi = { 1645 + searchPosts: async (_query, { cursor }) => { 1646 + capturedCursor = cursor; 1647 + return { posts: [{ uri: "p2", record: {} }], cursor: "fresh" }; 1648 + }, 1649 + getPosts: async () => [], 1650 + }; 1651 + const requests = makeRequests(mockApi, dataStore); 1652 + 1653 + await requests.loadHashtagFeed("foo", "top", { reload: true }); 1654 + 1655 + assertEquals(capturedCursor, ""); 1656 + const stored = dataStore.getHashtagFeed("foo-top"); 1657 + assertEquals(stored.feed.length, 1); 1658 + assertEquals(stored.feed[0].post.uri, "p2"); 1659 + }); 1660 + }); 1661 + 1662 + t.describe("loadPinnedFeedGenerators", (it) => { 1663 + it("should fan out to getFeedGenerators for pinned uris and cache results", async () => { 1664 + const pinnedFeedUris = [ 1665 + "at://did/feed/one", 1666 + "at://did/feed/two", 1667 + "following", 1668 + ]; 1669 + const preferences = { 1670 + getPinnedFeeds: () => pinnedFeedUris.map((value) => ({ value })), 1671 + }; 1672 + 1673 + let capturedUris; 1674 + const mockApi = { 1675 + getFeedGenerators: async (uris) => { 1676 + capturedUris = uris; 1677 + return uris.map((uri) => ({ uri, displayName: `name-${uri}` })); 1678 + }, 1679 + }; 1680 + const dataStore = new DataStore(); 1681 + const provider = { requirePreferences: () => preferences }; 1682 + const requests = createRequests(mockApi, dataStore, provider); 1683 + 1684 + await requests.loadPinnedFeedGenerators(); 1685 + 1686 + assertEquals(capturedUris, ["at://did/feed/one", "at://did/feed/two"]); 1687 + const pinned = dataStore.getPinnedFeedGenerators(); 1688 + assertEquals(pinned.length, 2); 1689 + assertEquals( 1690 + dataStore.getFeedGenerator("at://did/feed/one").displayName, 1691 + "name-at://did/feed/one", 1692 + ); 1693 + }); 1694 + 1695 + it("should skip the api call when no pinned feeds", async () => { 1696 + const preferences = { getPinnedFeeds: () => [] }; 1697 + let called = false; 1698 + const mockApi = { 1699 + getFeedGenerators: async () => { 1700 + called = true; 1701 + return []; 1702 + }, 1703 + }; 1704 + const dataStore = new DataStore(); 1705 + const provider = { requirePreferences: () => preferences }; 1706 + const requests = createRequests(mockApi, dataStore, provider); 1707 + 1708 + await requests.loadPinnedFeedGenerators(); 1709 + 1710 + assertEquals(called, false); 1711 + assertEquals(dataStore.getPinnedFeedGenerators(), []); 1712 + }); 1713 + }); 1714 + 1715 + t.describe("enableStatus / getStatus", (it) => { 1716 + it("should track loading start, end, and clear errors on success", async () => { 1717 + const mockApi = { getMutes: async () => ({ mutes: [], cursor: null }) }; 1718 + const dataStore = new DataStore(); 1719 + const requests = makeRequests(mockApi, dataStore); 1720 + 1721 + const initialStatus = requests.getStatus("loadMutedProfiles"); 1722 + assertEquals(initialStatus.loading, false); 1723 + assertEquals(initialStatus.error, null); 1724 + 1725 + const promise = requests.loadMutedProfiles(); 1726 + assertEquals(requests.getStatus("loadMutedProfiles").loading, true); 1727 + await promise; 1728 + 1729 + const finalStatus = requests.getStatus("loadMutedProfiles"); 1730 + assertEquals(finalStatus.loading, false); 1731 + assertEquals(finalStatus.error, null); 1732 + }); 1733 + 1734 + it("should record ApiError and clear loading on error path", async () => { 1735 + const apiError = new ApiError({ 1736 + status: 500, 1737 + statusText: "Server Error", 1738 + data: null, 1739 + headers: {}, 1740 + url: "/x", 1741 + }); 1742 + const mockApi = { 1743 + getMutes: async () => { 1744 + throw apiError; 1745 + }, 1746 + }; 1747 + const dataStore = new DataStore(); 1748 + const requests = makeRequests(mockApi, dataStore); 1749 + 1750 + await requests.loadMutedProfiles(); 1751 + 1752 + const status = requests.getStatus("loadMutedProfiles"); 1753 + assertEquals(status.loading, false); 1754 + assert( 1755 + status.error === apiError, 1756 + "expected status.error to be the ApiError", 1757 + ); 1758 + }); 1759 + 1760 + it("should rethrow non-ApiError and not record it on the status store", async () => { 1761 + const otherError = new Error("boom"); 1762 + const mockApi = { 1763 + getMutes: async () => { 1764 + throw otherError; 1765 + }, 1766 + }; 1767 + const dataStore = new DataStore(); 1768 + const requests = makeRequests(mockApi, dataStore); 1769 + 1770 + let caught = null; 1771 + try { 1772 + await requests.loadMutedProfiles(); 1773 + } catch (error) { 1774 + caught = error; 1775 + } 1776 + assert(caught === otherError, "expected non-ApiError to propagate"); 1777 + const status = requests.getStatus("loadMutedProfiles"); 1778 + assertEquals(status.loading, false); 1779 + assertEquals(status.error, null); 1780 + }); 1781 + 1782 + it("should namespace status by request id derived from arguments", async () => { 1783 + const dataStore = new DataStore(); 1784 + const mockApi = { 1785 + getProfile: async (did) => ({ did, handle: "x" }), 1786 + }; 1787 + const requests = makeRequests(mockApi, dataStore); 1788 + 1789 + await requests.loadProfile("did:plc:a"); 1790 + await requests.loadProfile("did:plc:b"); 1791 + 1792 + assertEquals(requests.getStatus("loadProfile-did:plc:a").error, null); 1793 + assertEquals(requests.getStatus("loadProfile-did:plc:a").loading, false); 1794 + assertEquals(requests.getStatus("loadProfile-did:plc:b").loading, false); 494 1795 }); 495 1796 }); 496 1797
+872
tests/unit/specs/dataLayer/selectors.test.js
··· 850 850 }); 851 851 }); 852 852 853 + t.describe("getNotifications", (it) => { 854 + let dataStore; 855 + let selectors; 856 + 857 + function makeSelectors(store) { 858 + const patchStore = new PatchStore(); 859 + const mockPreferencesProvider = { 860 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 861 + }; 862 + return new Selectors(store, patchStore, mockPreferencesProvider, false); 863 + } 864 + 865 + it("should return null when no notifications exist", () => { 866 + dataStore = new DataStore(); 867 + selectors = makeSelectors(dataStore); 868 + assertEquals(selectors.getNotifications(), null); 869 + }); 870 + 871 + it("should attach subject post for like notifications", () => { 872 + dataStore = new DataStore(); 873 + const subjectPost = { uri: "subjectPost", content: "liked post" }; 874 + dataStore.setPost("subjectPost", subjectPost); 875 + dataStore.setNotifications([ 876 + { 877 + reason: "like", 878 + reasonSubject: "subjectPost", 879 + uri: "notif1", 880 + }, 881 + ]); 882 + selectors = makeSelectors(dataStore); 883 + 884 + const result = selectors.getNotifications(); 885 + assertEquals(result.length, 1); 886 + assertEquals(result[0].subject, subjectPost); 887 + }); 888 + 889 + it("should fall back to unavailable post for like when subject missing", () => { 890 + dataStore = new DataStore(); 891 + dataStore.setNotifications([ 892 + { 893 + reason: "like", 894 + reasonSubject: "missingSubject", 895 + uri: "notif1", 896 + }, 897 + ]); 898 + selectors = makeSelectors(dataStore); 899 + 900 + const result = selectors.getNotifications(); 901 + assertEquals( 902 + result[0].subject.$type, 903 + "social.impro.feed.defs#unavailablePost", 904 + ); 905 + assertEquals(result[0].subject.uri, "missingSubject"); 906 + }); 907 + 908 + it("should attach subject post for repost notifications", () => { 909 + dataStore = new DataStore(); 910 + const subjectPost = { uri: "rpSubject", content: "reposted" }; 911 + dataStore.setPost("rpSubject", subjectPost); 912 + dataStore.setNotifications([ 913 + { 914 + reason: "repost", 915 + reasonSubject: "rpSubject", 916 + uri: "notif2", 917 + }, 918 + ]); 919 + selectors = makeSelectors(dataStore); 920 + 921 + const result = selectors.getNotifications(); 922 + assertEquals(result[0].subject, subjectPost); 923 + }); 924 + 925 + it("should attach subject post for like-via-repost notifications", () => { 926 + dataStore = new DataStore(); 927 + const subjectPost = { uri: "viaSubject", content: "via repost" }; 928 + dataStore.setPost("viaSubject", subjectPost); 929 + dataStore.setNotifications([ 930 + { 931 + reason: "like-via-repost", 932 + record: { subject: { uri: "viaSubject" } }, 933 + uri: "notif3", 934 + }, 935 + ]); 936 + selectors = makeSelectors(dataStore); 937 + 938 + const result = selectors.getNotifications(); 939 + assertEquals(result[0].subject, subjectPost); 940 + }); 941 + 942 + it("should fall back to unavailable post for like-via-repost when missing", () => { 943 + dataStore = new DataStore(); 944 + dataStore.setNotifications([ 945 + { 946 + reason: "repost-via-repost", 947 + record: { subject: { uri: "missingVia" } }, 948 + uri: "notif4", 949 + }, 950 + ]); 951 + selectors = makeSelectors(dataStore); 952 + 953 + const result = selectors.getNotifications(); 954 + assertEquals( 955 + result[0].subject.$type, 956 + "social.impro.feed.defs#unavailablePost", 957 + ); 958 + assertEquals(result[0].subject.uri, "missingVia"); 959 + }); 960 + 961 + it("should attach post and parentPost for reply notifications", () => { 962 + dataStore = new DataStore(); 963 + const replyPost = { uri: "replyUri", content: "the reply" }; 964 + const parentPost = { uri: "parentUri", content: "the parent" }; 965 + dataStore.setPost("replyUri", replyPost); 966 + dataStore.setPost("parentUri", parentPost); 967 + dataStore.setNotifications([ 968 + { 969 + reason: "reply", 970 + uri: "replyUri", 971 + record: { reply: { parent: { uri: "parentUri" } } }, 972 + }, 973 + ]); 974 + selectors = makeSelectors(dataStore); 975 + 976 + const result = selectors.getNotifications(); 977 + assertEquals(result[0].post, replyPost); 978 + assertEquals(result[0].parentPost, parentPost); 979 + }); 980 + 981 + it("should attach post for mention notifications without parent", () => { 982 + dataStore = new DataStore(); 983 + const mentionPost = { uri: "mentionUri", content: "mention" }; 984 + dataStore.setPost("mentionUri", mentionPost); 985 + dataStore.setNotifications([ 986 + { 987 + reason: "mention", 988 + uri: "mentionUri", 989 + }, 990 + ]); 991 + selectors = makeSelectors(dataStore); 992 + 993 + const result = selectors.getNotifications(); 994 + assertEquals(result[0].post, mentionPost); 995 + assertEquals(result[0].parentPost, null); 996 + }); 997 + 998 + it("should attach post and parentPost for quote notifications", () => { 999 + dataStore = new DataStore(); 1000 + const quotePost = { uri: "quoteUri", content: "quote" }; 1001 + const parentPost = { uri: "parentUri", content: "parent" }; 1002 + dataStore.setPost("quoteUri", quotePost); 1003 + dataStore.setPost("parentUri", parentPost); 1004 + dataStore.setNotifications([ 1005 + { 1006 + reason: "quote", 1007 + uri: "quoteUri", 1008 + record: { reply: { parent: { uri: "parentUri" } } }, 1009 + }, 1010 + ]); 1011 + selectors = makeSelectors(dataStore); 1012 + 1013 + const result = selectors.getNotifications(); 1014 + assertEquals(result[0].post, quotePost); 1015 + assertEquals(result[0].parentPost, parentPost); 1016 + }); 1017 + 1018 + it("should attach reasonSubject post for subscribed-post notifications", () => { 1019 + dataStore = new DataStore(); 1020 + const subPost = { uri: "subUri", content: "subscribed" }; 1021 + dataStore.setPost("subUri", subPost); 1022 + dataStore.setNotifications([ 1023 + { 1024 + reason: "subscribed-post", 1025 + uri: "subUri", 1026 + }, 1027 + ]); 1028 + selectors = makeSelectors(dataStore); 1029 + 1030 + const result = selectors.getNotifications(); 1031 + assertEquals(result[0].reasonSubject, subPost); 1032 + }); 1033 + 1034 + it("should pass follow notifications through unchanged", () => { 1035 + dataStore = new DataStore(); 1036 + const followNotification = { 1037 + reason: "follow", 1038 + uri: "followUri", 1039 + author: { did: "did:test:follower" }, 1040 + }; 1041 + dataStore.setNotifications([followNotification]); 1042 + selectors = makeSelectors(dataStore); 1043 + 1044 + const result = selectors.getNotifications(); 1045 + assertEquals(result[0], followNotification); 1046 + }); 1047 + }); 1048 + 1049 + t.describe("getMentionNotifications", (it) => { 1050 + function makeSelectors(dataStore) { 1051 + const patchStore = new PatchStore(); 1052 + const mockPreferencesProvider = { 1053 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1054 + }; 1055 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1056 + } 1057 + 1058 + it("should return null when none exist", () => { 1059 + const dataStore = new DataStore(); 1060 + assertEquals(makeSelectors(dataStore).getMentionNotifications(), null); 1061 + }); 1062 + 1063 + it("should attach post and parentPost for reply mentions", () => { 1064 + const dataStore = new DataStore(); 1065 + const replyPost = { uri: "rUri", content: "reply" }; 1066 + const parentPost = { uri: "pUri", content: "parent" }; 1067 + dataStore.setPost("rUri", replyPost); 1068 + dataStore.setPost("pUri", parentPost); 1069 + dataStore.setMentionNotifications([ 1070 + { 1071 + reason: "reply", 1072 + uri: "rUri", 1073 + record: { reply: { parent: { uri: "pUri" } } }, 1074 + }, 1075 + ]); 1076 + 1077 + const result = makeSelectors(dataStore).getMentionNotifications(); 1078 + assertEquals(result[0].post, replyPost); 1079 + assertEquals(result[0].parentPost, parentPost); 1080 + }); 1081 + 1082 + it("should attach post for mention without parent", () => { 1083 + const dataStore = new DataStore(); 1084 + const post = { uri: "mUri", content: "m" }; 1085 + dataStore.setPost("mUri", post); 1086 + dataStore.setMentionNotifications([{ reason: "mention", uri: "mUri" }]); 1087 + 1088 + const result = makeSelectors(dataStore).getMentionNotifications(); 1089 + assertEquals(result[0].post, post); 1090 + assertEquals(result[0].parentPost, null); 1091 + }); 1092 + 1093 + it("should pass non-reply/mention/quote notifications through unchanged", () => { 1094 + const dataStore = new DataStore(); 1095 + const followNotif = { reason: "follow", uri: "fUri" }; 1096 + dataStore.setMentionNotifications([followNotif]); 1097 + 1098 + const result = makeSelectors(dataStore).getMentionNotifications(); 1099 + assertEquals(result[0], followNotif); 1100 + }); 1101 + }); 1102 + 1103 + t.describe("getConvoList", (it) => { 1104 + function makeSelectors(dataStore) { 1105 + const patchStore = new PatchStore(); 1106 + const mockPreferencesProvider = { 1107 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1108 + }; 1109 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1110 + } 1111 + 1112 + it("should return null when convo list is missing", () => { 1113 + const dataStore = new DataStore(); 1114 + assertEquals(makeSelectors(dataStore).getConvoList(), null); 1115 + }); 1116 + 1117 + it("should sort convos by last interaction descending", () => { 1118 + const dataStore = new DataStore(); 1119 + const olderConvo = { 1120 + id: "convoOld", 1121 + members: [], 1122 + lastMessage: { 1123 + $type: "chat.bsky.convo.defs#messageView", 1124 + sentAt: "2024-01-01T00:00:00Z", 1125 + }, 1126 + }; 1127 + const newerConvo = { 1128 + id: "convoNew", 1129 + members: [], 1130 + lastMessage: { 1131 + $type: "chat.bsky.convo.defs#messageView", 1132 + sentAt: "2024-06-01T00:00:00Z", 1133 + }, 1134 + }; 1135 + dataStore.setConvo("convoOld", olderConvo); 1136 + dataStore.setConvo("convoNew", newerConvo); 1137 + dataStore.setConvoList([{ id: "convoOld" }, { id: "convoNew" }]); 1138 + 1139 + const result = makeSelectors(dataStore).getConvoList(); 1140 + assertEquals(result.length, 2); 1141 + assertEquals(result[0].id, "convoNew"); 1142 + assertEquals(result[1].id, "convoOld"); 1143 + }); 1144 + 1145 + it("should hydrate convos via getConvo", () => { 1146 + const dataStore = new DataStore(); 1147 + const fullConvo = { 1148 + id: "convo1", 1149 + members: [{ did: "did:test:a" }, { did: "did:test:b" }], 1150 + lastMessage: { 1151 + $type: "chat.bsky.convo.defs#messageView", 1152 + sentAt: "2024-01-01T00:00:00Z", 1153 + }, 1154 + }; 1155 + dataStore.setConvo("convo1", fullConvo); 1156 + dataStore.setConvoList([{ id: "convo1" }]); 1157 + 1158 + const result = makeSelectors(dataStore).getConvoList(); 1159 + assertEquals(result[0], fullConvo); 1160 + }); 1161 + }); 1162 + 1163 + t.describe("getConvoForProfile", (it) => { 1164 + function makeSelectors(dataStore) { 1165 + const patchStore = new PatchStore(); 1166 + const mockPreferencesProvider = { 1167 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1168 + }; 1169 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1170 + } 1171 + 1172 + it("should return null when no convo with the profile exists", () => { 1173 + const dataStore = new DataStore(); 1174 + dataStore.setConvo("convo1", { 1175 + id: "convo1", 1176 + members: [{ did: "did:test:a" }, { did: "did:test:b" }], 1177 + }); 1178 + const result = 1179 + makeSelectors(dataStore).getConvoForProfile("did:test:other"); 1180 + assertEquals(result, null); 1181 + }); 1182 + 1183 + it("should return the matching two-member convo", () => { 1184 + const dataStore = new DataStore(); 1185 + const convo = { 1186 + id: "convo1", 1187 + members: [{ did: "did:test:self" }, { did: "did:test:friend" }], 1188 + }; 1189 + dataStore.setConvo("convo1", convo); 1190 + const result = 1191 + makeSelectors(dataStore).getConvoForProfile("did:test:friend"); 1192 + assertEquals(result, convo); 1193 + }); 1194 + 1195 + it("should ignore group convos with more than two members", () => { 1196 + const dataStore = new DataStore(); 1197 + dataStore.setConvo("groupConvo", { 1198 + id: "groupConvo", 1199 + members: [ 1200 + { did: "did:test:a" }, 1201 + { did: "did:test:b" }, 1202 + { did: "did:test:c" }, 1203 + ], 1204 + }); 1205 + const result = makeSelectors(dataStore).getConvoForProfile("did:test:b"); 1206 + assertEquals(result, null); 1207 + }); 1208 + 1209 + it("should return null when there are no convos at all", () => { 1210 + const dataStore = new DataStore(); 1211 + const result = makeSelectors(dataStore).getConvoForProfile("did:test:any"); 1212 + assertEquals(result, null); 1213 + }); 1214 + }); 1215 + 1216 + t.describe("getBookmarks", (it) => { 1217 + function makeSelectors(dataStore) { 1218 + const patchStore = new PatchStore(); 1219 + const mockPreferencesProvider = { 1220 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1221 + }; 1222 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1223 + } 1224 + 1225 + it("should return null when bookmarks are missing", () => { 1226 + const dataStore = new DataStore(); 1227 + assertEquals(makeSelectors(dataStore).getBookmarks(), null); 1228 + }); 1229 + 1230 + it("should hydrate bookmarked posts via getPost", () => { 1231 + const dataStore = new DataStore(); 1232 + const post = { uri: "bm1", content: "bookmarked" }; 1233 + dataStore.setPost("bm1", post); 1234 + dataStore.setBookmarks({ 1235 + feed: [{ post: { uri: "bm1" } }], 1236 + cursor: "c1", 1237 + }); 1238 + 1239 + const result = makeSelectors(dataStore).getBookmarks(); 1240 + assertEquals(result.feed.length, 1); 1241 + assertEquals(result.feed[0].post, post); 1242 + assertEquals(result.cursor, "c1"); 1243 + }); 1244 + 1245 + it("should attach parentAuthor when bookmarked post is a reply", () => { 1246 + const dataStore = new DataStore(); 1247 + const parentPost = { 1248 + uri: "parentUri", 1249 + author: { did: "did:test:parent", handle: "parent.test" }, 1250 + }; 1251 + const replyPost = { 1252 + uri: "replyUri", 1253 + record: { reply: { parent: { uri: "parentUri" } } }, 1254 + }; 1255 + dataStore.setPost("parentUri", parentPost); 1256 + dataStore.setPost("replyUri", replyPost); 1257 + dataStore.setBookmarks({ 1258 + feed: [{ post: { uri: "replyUri" } }], 1259 + cursor: null, 1260 + }); 1261 + 1262 + const result = makeSelectors(dataStore).getBookmarks(); 1263 + assertEquals( 1264 + result.feed[0].post.record.reply.parentAuthor, 1265 + parentPost.author, 1266 + ); 1267 + }); 1268 + 1269 + it("should filter out bookmarks for blocked posts", () => { 1270 + const dataStore = new DataStore(); 1271 + const blockedPost = { 1272 + $type: "app.bsky.feed.defs#blockedPost", 1273 + uri: "blockedUri", 1274 + author: { did: "did:test:blocked", viewer: { blockedBy: true } }, 1275 + }; 1276 + const okPost = { uri: "okUri", content: "ok" }; 1277 + dataStore.setPost("blockedUri", blockedPost); 1278 + dataStore.setPost("okUri", okPost); 1279 + dataStore.setBookmarks({ 1280 + feed: [{ post: { uri: "blockedUri" } }, { post: { uri: "okUri" } }], 1281 + cursor: null, 1282 + }); 1283 + 1284 + const result = makeSelectors(dataStore).getBookmarks(); 1285 + assertEquals(result.feed.length, 1); 1286 + assertEquals(result.feed[0].post, okPost); 1287 + }); 1288 + }); 1289 + 1290 + t.describe("getAuthorFeed (non-replies)", (it) => { 1291 + const did = "did:test:alice"; 1292 + 1293 + function makeSelectors(dataStore) { 1294 + const patchStore = new PatchStore(); 1295 + const mockPreferencesProvider = { 1296 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1297 + }; 1298 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1299 + } 1300 + 1301 + it("should return null when feed is missing", () => { 1302 + const dataStore = new DataStore(); 1303 + const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1304 + assertEquals(result, null); 1305 + }); 1306 + 1307 + it("should keep top-level posts in posts feed", () => { 1308 + const dataStore = new DataStore(); 1309 + const post = { uri: "post1", author: { did } }; 1310 + dataStore.setAuthorFeed(`${did}-posts`, { 1311 + feed: [{ post: { uri: "post1" } }], 1312 + cursor: "c", 1313 + }); 1314 + dataStore.setPost("post1", post); 1315 + 1316 + const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1317 + assertEquals(result.feed.length, 1); 1318 + assertEquals(result.feed[0].post, post); 1319 + }); 1320 + 1321 + it("should keep replies in posts feed (no replies-only filter)", () => { 1322 + const dataStore = new DataStore(); 1323 + const replyPost = { uri: "post1", author: { did } }; 1324 + const parentPost = { uri: "parent1", author: { did } }; 1325 + const rootPost = { uri: "root1", author: { did } }; 1326 + dataStore.setAuthorFeed(`${did}-posts`, { 1327 + feed: [ 1328 + { 1329 + post: { uri: "post1" }, 1330 + reply: { 1331 + root: { uri: "root1" }, 1332 + parent: { uri: "parent1" }, 1333 + }, 1334 + }, 1335 + ], 1336 + cursor: "c", 1337 + }); 1338 + dataStore.setPost("post1", replyPost); 1339 + dataStore.setPost("parent1", parentPost); 1340 + dataStore.setPost("root1", rootPost); 1341 + 1342 + const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1343 + assertEquals(result.feed.length, 1); 1344 + assertEquals(result.feed[0].reply.root, rootPost); 1345 + assertEquals(result.feed[0].reply.parent, parentPost); 1346 + }); 1347 + 1348 + it("should preserve reason on reposts", () => { 1349 + const dataStore = new DataStore(); 1350 + const repostedPost = { uri: "post1", author: { did: "did:other" } }; 1351 + dataStore.setAuthorFeed(`${did}-posts`, { 1352 + feed: [ 1353 + { 1354 + post: { uri: "post1" }, 1355 + reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 1356 + }, 1357 + ], 1358 + cursor: "c", 1359 + }); 1360 + dataStore.setPost("post1", repostedPost); 1361 + 1362 + const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1363 + assertEquals(result.feed.length, 1); 1364 + assertEquals( 1365 + result.feed[0].reason.$type, 1366 + "app.bsky.feed.defs#reasonRepost", 1367 + ); 1368 + }); 1369 + 1370 + it("should pass through cursor", () => { 1371 + const dataStore = new DataStore(); 1372 + dataStore.setAuthorFeed(`${did}-posts`, { 1373 + feed: [], 1374 + cursor: "next-page-cursor", 1375 + }); 1376 + const result = makeSelectors(dataStore).getAuthorFeed(did, "posts"); 1377 + assertEquals(result.cursor, "next-page-cursor"); 1378 + }); 1379 + }); 1380 + 1381 + t.describe("filterAuthorRepliesFeed", (it) => { 1382 + function makeSelectors() { 1383 + const dataStore = new DataStore(); 1384 + const patchStore = new PatchStore(); 1385 + const mockPreferencesProvider = { 1386 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1387 + }; 1388 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1389 + } 1390 + 1391 + it("should keep items that have a reply and no reason", () => { 1392 + const selectors = makeSelectors(); 1393 + const reply = { post: { uri: "p1" }, reply: { root: {}, parent: {} } }; 1394 + const result = selectors.filterAuthorRepliesFeed({ 1395 + feed: [reply], 1396 + cursor: "c", 1397 + }); 1398 + assertEquals(result.feed, [reply]); 1399 + assertEquals(result.cursor, "c"); 1400 + }); 1401 + 1402 + it("should drop items without a reply", () => { 1403 + const selectors = makeSelectors(); 1404 + const result = selectors.filterAuthorRepliesFeed({ 1405 + feed: [{ post: { uri: "p1" } }], 1406 + cursor: null, 1407 + }); 1408 + assertEquals(result.feed.length, 0); 1409 + }); 1410 + 1411 + it("should drop items that have a reason (e.g. reposts) even if reply present", () => { 1412 + const selectors = makeSelectors(); 1413 + const item = { 1414 + post: { uri: "p1" }, 1415 + reply: { root: {}, parent: {} }, 1416 + reason: { $type: "app.bsky.feed.defs#reasonRepost" }, 1417 + }; 1418 + const result = selectors.filterAuthorRepliesFeed({ 1419 + feed: [item], 1420 + cursor: null, 1421 + }); 1422 + assertEquals(result.feed.length, 0); 1423 + }); 1424 + 1425 + it("should preserve cursor when filtering", () => { 1426 + const selectors = makeSelectors(); 1427 + const result = selectors.filterAuthorRepliesFeed({ 1428 + feed: [], 1429 + cursor: "abc", 1430 + }); 1431 + assertEquals(result.cursor, "abc"); 1432 + }); 1433 + }); 1434 + 1435 + t.describe("hydratePostThread (direct)", (it) => { 1436 + function makeSelectors(dataStore) { 1437 + const patchStore = new PatchStore(); 1438 + const mockPreferencesProvider = { 1439 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1440 + }; 1441 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1442 + } 1443 + 1444 + it("should hydrate the root post via getPost", () => { 1445 + const dataStore = new DataStore(); 1446 + const mainPost = { uri: "mainUri", content: "main" }; 1447 + dataStore.setPost("mainUri", mainPost); 1448 + const selectors = makeSelectors(dataStore); 1449 + 1450 + const result = selectors.hydratePostThread( 1451 + { post: { uri: "mainUri" }, replies: [] }, 1452 + [], 1453 + ); 1454 + assertEquals(result.post, mainPost); 1455 + assertEquals(result.replies, []); 1456 + }); 1457 + 1458 + it("should mark the root post hidden when its uri is in hiddenReplyUris", () => { 1459 + const dataStore = new DataStore(); 1460 + dataStore.setPost("mainUri", { uri: "mainUri", content: "main" }); 1461 + const selectors = makeSelectors(dataStore); 1462 + 1463 + const result = selectors.hydratePostThread( 1464 + { post: { uri: "mainUri" }, replies: [] }, 1465 + ["mainUri"], 1466 + ); 1467 + assertEquals(result.post.isHidden, true); 1468 + }); 1469 + 1470 + it("should recursively hydrate nested threadViewPost replies", () => { 1471 + const dataStore = new DataStore(); 1472 + dataStore.setPost("mainUri", { uri: "mainUri" }); 1473 + dataStore.setPost("childUri", { uri: "childUri", content: "child" }); 1474 + const selectors = makeSelectors(dataStore); 1475 + 1476 + const result = selectors.hydratePostThread( 1477 + { 1478 + post: { uri: "mainUri" }, 1479 + replies: [ 1480 + { 1481 + $type: "app.bsky.feed.defs#threadViewPost", 1482 + post: { uri: "childUri" }, 1483 + replies: [], 1484 + }, 1485 + ], 1486 + }, 1487 + [], 1488 + ); 1489 + assertEquals(result.replies[0].post.uri, "childUri"); 1490 + assertEquals(result.replies[0].post.content, "child"); 1491 + }); 1492 + 1493 + it("should pass non-threadViewPost replies through unchanged", () => { 1494 + const dataStore = new DataStore(); 1495 + dataStore.setPost("mainUri", { uri: "mainUri" }); 1496 + const selectors = makeSelectors(dataStore); 1497 + const otherReply = { 1498 + $type: "app.bsky.feed.defs#notFoundPost", 1499 + uri: "missingUri", 1500 + }; 1501 + const result = selectors.hydratePostThread( 1502 + { post: { uri: "mainUri" }, replies: [otherReply] }, 1503 + [], 1504 + ); 1505 + assertEquals(result.replies[0], otherReply); 1506 + }); 1507 + 1508 + it("should return blocked post unchanged when blocking user", () => { 1509 + const dataStore = new DataStore(); 1510 + const blockedThread = { 1511 + $type: "app.bsky.feed.defs#blockedPost", 1512 + uri: "blockedUri", 1513 + author: { did: "did:test:b", viewer: { blockedBy: true } }, 1514 + }; 1515 + const selectors = makeSelectors(dataStore); 1516 + const result = selectors.hydratePostThread(blockedThread, []); 1517 + assertEquals(result, blockedThread); 1518 + }); 1519 + }); 1520 + 1521 + t.describe("hydratePostThreadParent (direct)", (it) => { 1522 + function makeSelectors(dataStore) { 1523 + const patchStore = new PatchStore(); 1524 + const mockPreferencesProvider = { 1525 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1526 + }; 1527 + return new Selectors(dataStore, patchStore, mockPreferencesProvider, false); 1528 + } 1529 + 1530 + it("should hydrate a simple parent via getPost", () => { 1531 + const dataStore = new DataStore(); 1532 + const parentPost = { uri: "parentUri", content: "parent" }; 1533 + dataStore.setPost("parentUri", parentPost); 1534 + const selectors = makeSelectors(dataStore); 1535 + 1536 + const result = selectors.hydratePostThreadParent({ 1537 + $type: "app.bsky.feed.defs#threadViewPost", 1538 + post: { uri: "parentUri" }, 1539 + }); 1540 + assertEquals(result.post, parentPost); 1541 + assertEquals(result.$type, "app.bsky.feed.defs#threadViewPost"); 1542 + }); 1543 + 1544 + it("should walk up the parent chain recursively", () => { 1545 + const dataStore = new DataStore(); 1546 + dataStore.setPost("parentUri", { uri: "parentUri", content: "parent" }); 1547 + dataStore.setPost("grandUri", { uri: "grandUri", content: "grand" }); 1548 + const selectors = makeSelectors(dataStore); 1549 + 1550 + const result = selectors.hydratePostThreadParent({ 1551 + $type: "app.bsky.feed.defs#threadViewPost", 1552 + post: { uri: "parentUri" }, 1553 + parent: { 1554 + $type: "app.bsky.feed.defs#threadViewPost", 1555 + post: { uri: "grandUri" }, 1556 + }, 1557 + }); 1558 + assertEquals(result.post.uri, "parentUri"); 1559 + assertEquals(result.parent.post.uri, "grandUri"); 1560 + }); 1561 + 1562 + it("should return an unavailable post when parent uri is marked unavailable", () => { 1563 + const dataStore = new DataStore(); 1564 + dataStore.setUnavailablePost("missingParent", { uri: "missingParent" }); 1565 + const selectors = makeSelectors(dataStore); 1566 + 1567 + const result = selectors.hydratePostThreadParent({ 1568 + $type: "app.bsky.feed.defs#threadViewPost", 1569 + post: { uri: "missingParent" }, 1570 + uri: "missingParent", 1571 + }); 1572 + assertEquals(result.$type, "social.impro.feed.defs#unavailablePost"); 1573 + assertEquals(result.uri, "missingParent"); 1574 + }); 1575 + 1576 + it("should return blocked parent unchanged when blocking user", () => { 1577 + const dataStore = new DataStore(); 1578 + const blockedParent = { 1579 + $type: "app.bsky.feed.defs#blockedPost", 1580 + uri: "blockedParent", 1581 + author: { did: "did:test:b", viewer: { blockedBy: true } }, 1582 + }; 1583 + const selectors = makeSelectors(dataStore); 1584 + const result = selectors.hydratePostThreadParent(blockedParent); 1585 + assertEquals(result, blockedParent); 1586 + }); 1587 + 1588 + it("should return non-threadViewPost parent unchanged", () => { 1589 + const dataStore = new DataStore(); 1590 + const selectors = makeSelectors(dataStore); 1591 + const notFoundParent = { 1592 + $type: "app.bsky.feed.defs#notFoundPost", 1593 + uri: "missingUri", 1594 + }; 1595 + const result = selectors.hydratePostThreadParent(notFoundParent); 1596 + assertEquals(result, notFoundParent); 1597 + }); 1598 + }); 1599 + 1600 + t.describe("getPost muted-word marking", (it) => { 1601 + it("should mark post viewer.hasMutedWord when text contains a muted word", () => { 1602 + const dataStore = new DataStore(); 1603 + const patchStore = new PatchStore(); 1604 + let preferences = Preferences.createLoggedOutPreferences(); 1605 + preferences = preferences.addMutedWord({ 1606 + value: "spoiler", 1607 + targets: ["content"], 1608 + actorTarget: "all", 1609 + expiresAt: null, 1610 + }); 1611 + const mockPreferencesProvider = { requirePreferences: () => preferences }; 1612 + const selectors = new Selectors( 1613 + dataStore, 1614 + patchStore, 1615 + mockPreferencesProvider, 1616 + false, 1617 + ); 1618 + 1619 + const post = { 1620 + uri: "mutedPost", 1621 + record: { text: "this is a spoiler about the show" }, 1622 + viewer: {}, 1623 + }; 1624 + dataStore.setPost("mutedPost", post); 1625 + 1626 + const result = selectors.getPost("mutedPost"); 1627 + assertEquals(result.viewer.hasMutedWord, true); 1628 + }); 1629 + 1630 + it("should not mark posts that don't match a muted word", () => { 1631 + const dataStore = new DataStore(); 1632 + const patchStore = new PatchStore(); 1633 + let preferences = Preferences.createLoggedOutPreferences(); 1634 + preferences = preferences.addMutedWord({ 1635 + value: "spoiler", 1636 + targets: ["content"], 1637 + actorTarget: "all", 1638 + expiresAt: null, 1639 + }); 1640 + const mockPreferencesProvider = { requirePreferences: () => preferences }; 1641 + const selectors = new Selectors( 1642 + dataStore, 1643 + patchStore, 1644 + mockPreferencesProvider, 1645 + false, 1646 + ); 1647 + 1648 + const post = { 1649 + uri: "okPost", 1650 + record: { text: "this is fine" }, 1651 + viewer: {}, 1652 + }; 1653 + dataStore.setPost("okPost", post); 1654 + 1655 + const result = selectors.getPost("okPost"); 1656 + assertEquals(result.viewer.hasMutedWord, undefined); 1657 + }); 1658 + }); 1659 + 1660 + t.describe("getPost hidden-post marking", (it) => { 1661 + it("should mark post viewer.isHidden when uri is in hidden posts preference", () => { 1662 + const dataStore = new DataStore(); 1663 + const patchStore = new PatchStore(); 1664 + let preferences = Preferences.createLoggedOutPreferences(); 1665 + preferences = preferences.hidePost("hiddenUri"); 1666 + const mockPreferencesProvider = { requirePreferences: () => preferences }; 1667 + const selectors = new Selectors( 1668 + dataStore, 1669 + patchStore, 1670 + mockPreferencesProvider, 1671 + false, 1672 + ); 1673 + 1674 + const post = { uri: "hiddenUri", viewer: {} }; 1675 + dataStore.setPost("hiddenUri", post); 1676 + 1677 + const result = selectors.getPost("hiddenUri"); 1678 + assertEquals(result.viewer.isHidden, true); 1679 + }); 1680 + 1681 + it("should leave viewer.isHidden unset for non-hidden posts", () => { 1682 + const dataStore = new DataStore(); 1683 + const patchStore = new PatchStore(); 1684 + const preferences = Preferences.createLoggedOutPreferences(); 1685 + const mockPreferencesProvider = { requirePreferences: () => preferences }; 1686 + const selectors = new Selectors( 1687 + dataStore, 1688 + patchStore, 1689 + mockPreferencesProvider, 1690 + false, 1691 + ); 1692 + 1693 + const post = { uri: "visibleUri", viewer: {} }; 1694 + dataStore.setPost("visibleUri", post); 1695 + 1696 + const result = selectors.getPost("visibleUri"); 1697 + assertEquals(result.viewer.isHidden, undefined); 1698 + }); 1699 + }); 1700 + 1701 + t.describe("getPost label handling", (it) => { 1702 + it("should not attach badgeLabels/contentLabel/mediaLabel for unlabeled posts", () => { 1703 + const dataStore = new DataStore(); 1704 + const patchStore = new PatchStore(); 1705 + const mockPreferencesProvider = { 1706 + requirePreferences: () => Preferences.createLoggedOutPreferences(), 1707 + }; 1708 + const selectors = new Selectors( 1709 + dataStore, 1710 + patchStore, 1711 + mockPreferencesProvider, 1712 + false, 1713 + ); 1714 + 1715 + const post = { uri: "plainUri", viewer: {} }; 1716 + dataStore.setPost("plainUri", post); 1717 + 1718 + const result = selectors.getPost("plainUri"); 1719 + assertEquals(result.badgeLabels, undefined); 1720 + assertEquals(result.contentLabel, undefined); 1721 + assertEquals(result.mediaLabel, undefined); 1722 + }); 1723 + }); 1724 + 853 1725 await t.run();