ff-8d39d39ff6ef655b9851e87252b48e1af574f452R1735">1735
+       "description": "Calendar identifier.",
1736
+       "required": true,
1737
+       "location": "path"
1738
+      }
1739
+     },
1740
+     "parameterOrder": [
1741
+      "calendarId"
1742
+     ],
1743
+     "request": {
1744
+      "$ref": "Calendar"
1745
+     },
1746
+     "response": {
1747
+      "$ref": "Calendar"
1748
+     },
1749
+     "scopes": [
1750
+      "https://www.googleapis.com/auth/calendar"
1751
+     ]
1752
+    },
1753
+    "update": {
1754
+     "id": "calendar.calendars.update",
1755
+     "path": "calendars/{calendarId}",
1756
+     "httpMethod": "PUT",
1757
+     "description": "Updates metadata for a calendar.",
1758
+     "parameters": {
1759
+      "calendarId": {
1760
+       "type": "string",
1761
+       "description": "Calendar identifier.",
1762
+       "required": true,
1763
+       "location": "path"
1764
+      }
1765
+     },
1766
+     "parameterOrder": [
1767
+      "calendarId"
1768
+     ],
1769
+     "request": {
1770
+      "$ref": "Calendar"
1771
+     },
1772
+     "response": {
1773
+      "$ref": "Calendar"
1774
+     },
1775
+     "scopes": [
1776
+      "https://www.googleapis.com/auth/calendar"
1777
+     ]
1778
+    }
1779
+   }
1780
+  },
1781
+  "channels": {
1782
+   "methods": {
1783
+    "stop": {
1784
+     "id": "calendar.channels.stop",
1785
+     "path": "channels/stop",
1786
+     "httpMethod": "POST",
1787
+     "description": "Stop watching resources through this channel",
1788
+     "request": {
1789
+      "$ref": "Channel",
1790
+      "parameterName": "resource"
1791
+     },
1792
+     "scopes": [
1793
+      "https://www.googleapis.com/auth/calendar",
1794
+      "https://www.googleapis.com/auth/calendar.readonly"
1795
+     ]
1796
+    }
1797
+   }
1798
+  },
1799
+  "colors": {
1800
+   "methods": {
1801
+    "get": {
1802
+     "id": "calendar.colors.get",
1803
+     "path": "colors",
1804
+     "httpMethod": "GET",
1805
+     "description": "Returns the color definitions for calendars and events.",
1806
+     "response": {
1807
+      "$ref": "Colors"
1808
+     },
1809
+     "scopes": [
1810
+      "https://www.googleapis.com/auth/calendar",
1811
+      "https://www.googleapis.com/auth/calendar.readonly"
1812
+     ]
1813
+    }
1814
+   }
1815
+  },
1816
+  "events": {
1817
+   "methods": {
1818
+    "delete": {
1819
+     "id": "calendar.events.delete",
1820
+     "path": "calendars/{calendarId}/events/{eventId}",
1821
+     "httpMethod": "DELETE",
1822
+     "description": "Deletes an event.",
1823
+     "parameters": {
1824
+      "calendarId": {
1825
+       "type": "string",
1826
+       "description": "Calendar identifier.",
1827
+       "required": true,
1828
+       "location": "path"
1829
+      },
1830
+      "eventId": {
1831
+       "type": "string",
1832
+       "description": "Event identifier.",
1833
+       "required": true,
1834
+       "location": "path"
1835
+      },
1836
+      "sendNotifications": {
1837
+       "type": "boolean",
1838
+       "description": "Whether to send notifications about the deletion of the event. Optional. The default is False.",
1839
+       "location": "query"
1840
+      }
1841
+     },
1842
+     "parameterOrder": [
1843
+      "calendarId",
1844
+      "eventId"
1845
+     ],
1846
+     "scopes": [
1847
+      "https://www.googleapis.com/auth/calendar"
1848
+     ]
1849
+    },
1850
+    "get": {
1851
+     "id": "calendar.events.get",
1852
+     "path": "calendars/{calendarId}/events/{eventId}",
1853
+     "httpMethod": "GET",
1854
+     "description": "Returns an event.",
1855
+     "parameters": {
1856
+      "alwaysIncludeEmail": {
1857
+       "type": "boolean",
1858
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
1859
+       "location": "query"
1860
+      },
1861
+      "calendarId": {
1862
+       "type": "string",
1863
+       "description": "Calendar identifier.",
1864
+       "required": true,
1865
+       "location": "path"
1866
+      },
1867
+      "eventId": {
1868
+       "type": "string",
1869
+       "description": "Event identifier.",
1870
+       "required": true,
1871
+       "location": "path"
1872
+      },
1873
+      "maxAttendees": {
1874
+       "type": "integer",
1875
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1876
+       "format": "int32",
1877
+       "minimum": "1",
1878
+       "location": "query"
1879
+      },
1880
+      "timeZone": {
1881
+       "type": "string",
1882
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
1883
+       "location": "query"
1884
+      }
1885
+     },
1886
+     "parameterOrder": [
1887
+      "calendarId",
1888
+      "eventId"
1889
+     ],
1890
+     "response": {
1891
+      "$ref": "Event"
1892
+     },
1893
+     "scopes": [
1894
+      "https://www.googleapis.com/auth/calendar",
1895
+      "https://www.googleapis.com/auth/calendar.readonly"
1896
+     ]
1897
+    },
1898
+    "import": {
1899
+     "id": "calendar.events.import",
1900
+     "path": "calendars/{calendarId}/events/import",
1901
+     "httpMethod": "POST",
1902
+     "description": "Imports an event. This operation is used to add a private copy of an existing event to a calendar.",
1903
+     "parameters": {
1904
+      "calendarId": {
1905
+       "type": "string",
1906
+       "description": "Calendar identifier.",
1907
+       "required": true,
1908
+       "location": "path"
1909
+      }
1910
+     },
1911
+     "parameterOrder": [
1912
+      "calendarId"
1913
+     ],
1914
+     "request": {
1915
+      "$ref": "Event"
1916
+     },
1917
+     "response": {
1918
+      "$ref": "Event"
1919
+     },
1920
+     "scopes": [
1921
+      "https://www.googleapis.com/auth/calendar"
1922
+     ]
1923
+    },
1924
+    "insert": {
1925
+     "id": "calendar.events.insert",
1926
+     "path": "calendars/{calendarId}/events",
1927
+     "httpMethod": "POST",
1928
+     "description": "Creates an event.",
1929
+     "parameters": {
1930
+      "calendarId": {
1931
+       "type": "string",
1932
+       "description": "Calendar identifier.",
1933
+       "required": true,
1934
+       "location": "path"
1935
+      },
1936
+      "maxAttendees": {
1937
+       "type": "integer",
1938
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1939
+       "format": "int32",
1940
+       "minimum": "1",
1941
+       "location": "query"
1942
+      },
1943
+      "sendNotifications": {
1944
+       "type": "boolean",
1945
+       "description": "Whether to send notifications about the creation of the new event. Optional. The default is False.",
1946
+       "location": "query"
1947
+      }
1948
+     },
1949
+     "parameterOrder": [
1950
+      "calendarId"
1951
+     ],
1952
+     "request": {
1953
+      "$ref": "Event"
1954
+     },
1955
+     "response": {
1956
+      "$ref": "Event"
1957
+     },
1958
+     "scopes": [
1959
+      "https://www.googleapis.com/auth/calendar"
1960
+     ]
1961
+    },
1962
+    "instances": {
1963
+     "id": "calendar.events.instances",
1964
+     "path": "calendars/{calendarId}/events/{eventId}/instances",
1965
+     "httpMethod": "GET",
1966
+     "description": "Returns instances of the specified recurring event.",
1967
+     "parameters": {
1968
+      "alwaysIncludeEmail": {
1969
+       "type": "boolean",
1970
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
1971
+       "location": "query"
1972
+      },
1973
+      "calendarId": {
1974
+       "type": "string",
1975
+       "description": "Calendar identifier.",
1976
+       "required": true,
1977
+       "location": "path"
1978
+      },
1979
+      "eventId": {
1980
+       "type": "string",
1981
+       "description": "Recurring event identifier.",
1982
+       "required": true,
1983
+       "location": "path"
1984
+      },
1985
+      "maxAttendees": {
1986
+       "type": "integer",
1987
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
1988
+       "format": "int32",
1989
+       "minimum": "1",
1990
+       "location": "query"
1991
+      },
1992
+      "maxResults": {
1993
+       "type": "integer",
1994
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
1995
+       "format": "int32",
1996
+       "minimum": "1",
1997
+       "location": "query"
1998
+      },
1999
+      "originalStart": {
2000
+       "type": "string",
2001
+       "description": "The original start time of the instance in the result. Optional.",
2002
+       "location": "query"
2003
+      },
2004
+      "pageToken": {
2005
+       "type": "string",
2006
+       "description": "Token specifying which result page to return. Optional.",
2007
+       "location": "query"
2008
+      },
2009
+      "showDeleted": {
2010
+       "type": "boolean",
2011
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events will still be included if singleEvents is False. Optional. The default is False.",
2012
+       "location": "query"
2013
+      },
2014
+      "timeMax": {
2015
+       "type": "string",
2016
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2017
+       "format": "date-time",
2018
+       "location": "query"
2019
+      },
2020
+      "timeMin": {
2021
+       "type": "string",
2022
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2023
+       "format": "date-time",
2024
+       "location": "query"
2025
+      },
2026
+      "timeZone": {
2027
+       "type": "string",
2028
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2029
+       "location": "query"
2030
+      }
2031
+     },
2032
+     "parameterOrder": [
2033
+      "calendarId",
2034
+      "eventId"
2035
+     ],
2036
+     "response": {
2037
+      "$ref": "Events"
2038
+     },
2039
+     "scopes": [
2040
+      "https://www.googleapis.com/auth/calendar",
2041
+      "https://www.googleapis.com/auth/calendar.readonly"
2042
+     ],
2043
+     "supportsSubscription": true
2044
+    },
2045
+    "list": {
2046
+     "id": "calendar.events.list",
2047
+     "path": "calendars/{calendarId}/events",
2048
+     "httpMethod": "GET",
2049
+     "description": "Returns events on the specified calendar.",
2050
+     "parameters": {
2051
+      "alwaysIncludeEmail": {
2052
+       "type": "boolean",
2053
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2054
+       "location": "query"
2055
+      },
2056
+      "calendarId": {
2057
+       "type": "string",
2058
+       "description": "Calendar identifier.",
2059
+       "required": true,
2060
+       "location": "path"
2061
+      },
2062
+      "iCalUID": {
2063
+       "type": "string",
2064
+       "description": "Specifies event ID in the iCalendar format to be included in the response. Optional.",
2065
+       "location": "query"
2066
+      },
2067
+      "maxAttendees": {
2068
+       "type": "integer",
2069
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2070
+       "format": "int32",
2071
+       "minimum": "1",
2072
+       "location": "query"
2073
+      },
2074
+      "maxResults": {
2075
+       "type": "integer",
2076
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
2077
+       "format": "int32",
2078
+       "minimum": "1",
2079
+       "location": "query"
2080
+      },
2081
+      "orderBy": {
2082
+       "type": "string",
2083
+       "description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order.",
2084
+       "enum": [
2085
+        "startTime",
2086
+        "updated"
2087
+       ],
2088
+       "enumDescriptions": [
2089
+        "Order by the start date/time (ascending). This is only available when querying single events (i.e. the parameter singleEvents is True)",
2090
+        "Order by last modification time (ascending)."
2091
+       ],
2092
+       "location": "query"
2093
+      },
2094
+      "pageToken": {
2095
+       "type": "string",
2096
+       "description": "Token specifying which result page to return. Optional.",
2097
+       "location": "query"
2098
+      },
2099
+      "privateExtendedProperty": {
2100
+       "type": "string",
2101
+       "description": "Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2102
+       "repeated": true,
2103
+       "location": "query"
2104
+      },
2105
+      "q": {
2106
+       "type": "string",
2107
+       "description": "Free text search terms to find events that match these terms in any field, except for extended properties. Optional.",
2108
+       "location": "query"
2109
+      },
2110
+      "sharedExtendedProperty": {
2111
+       "type": "string",
2112
+       "description": "Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2113
+       "repeated": true,
2114
+       "location": "query"
2115
+      },
2116
+      "showDeleted": {
2117
+       "type": "boolean",
2118
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events (but not the underlying recurring event) will still be included if showDeleted and singleEvents are both False. If showDeleted and singleEvents are both True, only single instances of deleted events (but not the underlying recurring events) are returned. Optional. The default is False.",
2119
+       "location": "query"
2120
+      },
2121
+      "showHiddenInvitations": {
2122
+       "type": "boolean",
2123
+       "description": "Whether to include hidden invitations in the result. Optional. The default is False.",
2124
+       "location": "query"
2125
+      },
2126
+      "singleEvents": {
2127
+       "type": "boolean",
2128
+       "description": "Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves. Optional. The default is False.",
2129
+       "location": "query"
2130
+      },
2131
+      "syncToken": {
2132
+       "type": "string",
2133
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All events deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nThere are several query parameters that cannot be specified together with nextSyncToken to ensure consistency of the client state.\n\nThese are: \n- iCalUID \n- orderBy \n- privateExtendedProperty \n- q \n- sharedExtendedProperty \n- timeMin \n- timeMax \n- updatedMin If the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2134
+       "location": "query"
2135
+      },
2136
+      "timeMax": {
2137
+       "type": "string",
2138
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2139
+       "format": "date-time",
2140
+       "location": "query"
2141
+      },
2142
+      "timeMin": {
2143
+       "type": "string",
2144
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2145
+       "format": "date-time",
2146
+       "location": "query"
2147
+      },
2148
+      "timeZone": {
2149
+       "type": "string",
2150
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2151
+       "location": "query"
2152
+      },
2153
+      "updatedMin": {
2154
+       "type": "string",
2155
+       "description": "Lower bound for an event's last modification time (as a RFC 3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time.",
2156
+       "format": "date-time",
2157
+       "location": "query"
2158
+      }
2159
+     },
2160
+     "parameterOrder": [
2161
+      "calendarId"
2162
+     ],
2163
+     "response": {
2164
+      "$ref": "Events"
2165
+     },
2166
+     "scopes": [
2167
+      "https://www.googleapis.com/auth/calendar",
2168
+      "https://www.googleapis.com/auth/calendar.readonly"
2169
+     ],
2170
+     "supportsSubscription": true
2171
+    },
2172
+    "move": {
2173
+     "id": "calendar.events.move",
2174
+     "path": "calendars/{calendarId}/events/{eventId}/move",
2175
+     "httpMethod": "POST",
2176
+     "description": "Moves an event to another calendar, i.e. changes an event's organizer.",
2177
+     "parameters": {
2178
+      "calendarId": {
2179
+       "type": "string",
2180
+       "description": "Calendar identifier of the source calendar where the event currently is on.",
2181
+       "required": true,
2182
+       "location": "path"
2183
+      },
2184
+      "destination": {
2185
+       "type": "string",
2186
+       "description": "Calendar identifier of the target calendar where the event is to be moved to.",
2187
+       "required": true,
2188
+       "location": "query"
2189
+      },
2190
+      "eventId": {
2191
+       "type": "string",
2192
+       "description": "Event identifier.",
2193
+       "required": true,
2194
+       "location": "path"
2195
+      },
2196
+      "sendNotifications": {
2197
+       "type": "boolean",
2198
+       "description": "Whether to send notifications about the change of the event's organizer. Optional. The default is False.",
2199
+       "location": "query"
2200
+      }
2201
+     },
2202
+     "parameterOrder": [
2203
+      "calendarId",
2204
+      "eventId",
2205
+      "destination"
2206
+     ],
2207
+     "response": {
2208
+      "$ref": "Event"
2209
+     },
2210
+     "scopes": [
2211
+      "https://www.googleapis.com/auth/calendar"
2212
+     ]
2213
+    },
2214
+    "patch": {
2215
+     "id": "calendar.events.patch",
2216
+     "path": "calendars/{calendarId}/events/{eventId}",
2217
+     "httpMethod": "PATCH",
2218
+     "description": "Updates an event. This method supports patch semantics.",
2219
+     "parameters": {
2220
+      "alwaysIncludeEmail": {
2221
+       "type": "boolean",
2222
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2223
+       "location": "query"
2224
+      },
2225
+      "calendarId": {
2226
+       "type": "string",
2227
+       "description": "Calendar identifier.",
2228
+       "required": true,
2229
+       "location": "path"
2230
+      },
2231
+      "eventId": {
2232
+       "type": "string",
2233
+       "description": "Event identifier.",
2234
+       "required": true,
2235
+       "location": "path"
2236
+      },
2237
+      "maxAttendees": {
2238
+       "type": "integer",
2239
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2240
+       "format": "int32",
2241
+       "minimum": "1",
2242
+       "location": "query"
2243
+      },
2244
+      "sendNotifications": {
2245
+       "type": "boolean",
2246
+       "description": "Whether to send notifications about the event update (e.g. attendee's responses, title changes, etc.). Optional. The default is False.",
2247
+       "location": "query"
2248
+      }
2249
+     },
2250
+     "parameterOrder": [
2251
+      "calendarId",
2252
+      "eventId"
2253
+     ],
2254
+     "request": {
2255
+      "$ref": "Event"
2256
+     },
2257
+     "response": {
2258
+      "$ref": "Event"
2259
+     },
2260
+     "scopes": [
2261
+      "https://www.googleapis.com/auth/calendar"
2262
+     ]
2263
+    },
2264
+    "quickAdd": {
2265
+     "id": "calendar.events.quickAdd",
2266
+     "path": "calendars/{calendarId}/events/quickAdd",
2267
+     "httpMethod": "POST",
2268
+     "description": "Creates an event based on a simple text string.",
2269
+     "parameters": {
2270
+      "calendarId": {
2271
+       "type": "string",
2272
+       "description": "Calendar identifier.",
2273
+       "required": true,
2274
+       "location": "path"
2275
+      },
2276
+      "sendNotifications": {
2277
+       "type": "boolean",
2278
+       "description": "Whether to send notifications about the creation of the event. Optional. The default is False.",
2279
+       "location": "query"
2280
+      },
2281
+      "text": {
2282
+       "type": "string",
2283
+       "description": "The text describing the event to be created.",
2284
+       "required": true,
2285
+       "location": "query"
2286
+      }
2287
+     },
2288
+     "parameterOrder": [
2289
+      "calendarId",
2290
+      "text"
2291
+     ],
2292
+     "response": {
2293
+      "$ref": "Event"
2294
+     },
2295
+     "scopes": [
2296
+      "https://www.googleapis.com/auth/calendar"
2297
+     ]
2298
+    },
2299
+    "update": {
2300
+     "id": "calendar.events.update",
2301
+     "path": "calendars/{calendarId}/events/{eventId}",
2302
+     "httpMethod": "PUT",
2303
+     "description": "Updates an event.",
2304
+     "parameters": {
2305
+      "alwaysIncludeEmail": {
2306
+       "type": "boolean",
2307
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2308
+       "location": "query"
2309
+      },
2310
+      "calendarId": {
2311
+       "type": "string",
2312
+       "description": "Calendar identifier.",
2313
+       "required": true,
2314
+       "location": "path"
2315
+      },
2316
+      "eventId": {
2317
+       "type": "string",
2318
+       "description": "Event identifier.",
2319
+       "required": true,
2320
+       "location": "path"
2321
+      },
2322
+      "maxAttendees": {
2323
+       "type": "integer",
2324
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2325
+       "format": "int32",
2326
+       "minimum": "1",
2327
+       "location": "query"
2328
+      },
2329
+      "sendNotifications": {
2330
+       "type": "boolean",
2331
+       "description": "Whether to send notifications about the event update (e.g. attendee's responses, title changes, etc.). Optional. The default is False.",
2332
+       "location": "query"
2333
+      }
2334
+     },
2335
+     "parameterOrder": [
2336
+      "calendarId",
2337
+      "eventId"
2338
+     ],
2339
+     "request": {
2340
+      "$ref": "Event"
2341
+     },
2342
+     "response": {
2343
+      "$ref": "Event"
2344
+     },
2345
+     "scopes": [
2346
+      "https://www.googleapis.com/auth/calendar"
2347
+     ]
2348
+    },
2349
+    "watch": {
2350
+     "id": "calendar.events.watch",
2351
+     "path": "calendars/{calendarId}/events/watch",
2352
+     "httpMethod": "POST",
2353
+     "description": "Watch for changes to Events resources.",
2354
+     "parameters": {
2355
+      "alwaysIncludeEmail": {
2356
+       "type": "boolean",
2357
+       "description": "Whether to always include a value in the email field for the organizer, creator and attendees, even if no real email is available (i.e. a generated, non-working value will be provided). The use of this option is discouraged and should only be used by clients which cannot handle the absence of an email address value in the mentioned places. Optional. The default is False.",
2358
+       "location": "query"
2359
+      },
2360
+      "calendarId": {
2361
+       "type": "string",
2362
+       "description": "Calendar identifier.",
2363
+       "required": true,
2364
+       "location": "path"
2365
+      },
2366
+      "iCalUID": {
2367
+       "type": "string",
2368
+       "description": "Specifies event ID in the iCalendar format to be included in the response. Optional.",
2369
+       "location": "query"
2370
+      },
2371
+      "maxAttendees": {
2372
+       "type": "integer",
2373
+       "description": "The maximum number of attendees to include in the response. If there are more than the specified number of attendees, only the participant is returned. Optional.",
2374
+       "format": "int32",
2375
+       "minimum": "1",
2376
+       "location": "query"
2377
+      },
2378
+      "maxResults": {
2379
+       "type": "integer",
2380
+       "description": "Maximum number of events returned on one result page. By default the value is 250 events. The page size can never be larger than 2500 events. Optional.",
2381
+       "format": "int32",
2382
+       "minimum": "1",
2383
+       "location": "query"
2384
+      },
2385
+      "orderBy": {
2386
+       "type": "string",
2387
+       "description": "The order of the events returned in the result. Optional. The default is an unspecified, stable order.",
2388
+       "enum": [
2389
+        "startTime",
2390
+        "updated"
2391
+       ],
2392
+       "enumDescriptions": [
2393
+        "Order by the start date/time (ascending). This is only available when querying single events (i.e. the parameter singleEvents is True)",
2394
+        "Order by last modification time (ascending)."
2395
+       ],
2396
+       "location": "query"
2397
+      },
2398
+      "pageToken": {
2399
+       "type": "string",
2400
+       "description": "Token specifying which result page to return. Optional.",
2401
+       "location": "query"
2402
+      },
2403
+      "privateExtendedProperty": {
2404
+       "type": "string",
2405
+       "description": "Extended properties constraint specified as propertyName=value. Matches only private properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2406
+       "repeated": true,
2407
+       "location": "query"
2408
+      },
2409
+      "q": {
2410
+       "type": "string",
2411
+       "description": "Free text search terms to find events that match these terms in any field, except for extended properties. Optional.",
2412
+       "location": "query"
2413
+      },
2414
+      "sharedExtendedProperty": {
2415
+       "type": "string",
2416
+       "description": "Extended properties constraint specified as propertyName=value. Matches only shared properties. This parameter might be repeated multiple times to return events that match all given constraints.",
2417
+       "repeated": true,
2418
+       "location": "query"
2419
+      },
2420
+      "showDeleted": {
2421
+       "type": "boolean",
2422
+       "description": "Whether to include deleted events (with status equals \"cancelled\") in the result. Cancelled instances of recurring events (but not the underlying recurring event) will still be included if showDeleted and singleEvents are both False. If showDeleted and singleEvents are both True, only single instances of deleted events (but not the underlying recurring events) are returned. Optional. The default is False.",
2423
+       "location": "query"
2424
+      },
2425
+      "showHiddenInvitations": {
2426
+       "type": "boolean",
2427
+       "description": "Whether to include hidden invitations in the result. Optional. The default is False.",
2428
+       "location": "query"
2429
+      },
2430
+      "singleEvents": {
2431
+       "type": "boolean",
2432
+       "description": "Whether to expand recurring events into instances and only return single one-off events and instances of recurring events, but not the underlying recurring events themselves. Optional. The default is False.",
2433
+       "location": "query"
2434
+      },
2435
+      "syncToken": {
2436
+       "type": "string",
2437
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then. All events deleted since the previous list request will always be in the result set and it is not allowed to set showDeleted to False.\nThere are several query parameters that cannot be specified together with nextSyncToken to ensure consistency of the client state.\n\nThese are: \n- iCalUID \n- orderBy \n- privateExtendedProperty \n- q \n- sharedExtendedProperty \n- timeMin \n- timeMax \n- updatedMin If the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2438
+       "location": "query"
2439
+      },
2440
+      "timeMax": {
2441
+       "type": "string",
2442
+       "description": "Upper bound (exclusive) for an event's start time to filter by. Optional. The default is not to filter by start time.",
2443
+       "format": "date-time",
2444
+       "location": "query"
2445
+      },
2446
+      "timeMin": {
2447
+       "type": "string",
2448
+       "description": "Lower bound (inclusive) for an event's end time to filter by. Optional. The default is not to filter by end time.",
2449
+       "format": "date-time",
2450
+       "location": "query"
2451
+      },
2452
+      "timeZone": {
2453
+       "type": "string",
2454
+       "description": "Time zone used in the response. Optional. The default is the time zone of the calendar.",
2455
+       "location": "query"
2456
+      },
2457
+      "updatedMin": {
2458
+       "type": "string",
2459
+       "description": "Lower bound for an event's last modification time (as a RFC 3339 timestamp) to filter by. When specified, entries deleted since this time will always be included regardless of showDeleted. Optional. The default is not to filter by last modification time.",
2460
+       "format": "date-time",
2461
+       "location": "query"
2462
+      }
2463
+     },
2464
+     "parameterOrder": [
2465
+      "calendarId"
2466
+     ],
2467
+     "request": {
2468
+      "$ref": "Channel",
2469
+      "parameterName": "resource"
2470
+     },
2471
+     "response": {
2472
+      "$ref": "Channel"
2473
+     },
2474
+     "scopes": [
2475
+      "https://www.googleapis.com/auth/calendar",
2476
+      "https://www.googleapis.com/auth/calendar.readonly"
2477
+     ],
2478
+     "supportsSubscription": true
2479
+    }
2480
+   }
2481
+  },
2482
+  "freebusy": {
2483
+   "methods": {
2484
+    "query": {
2485
+     "id": "calendar.freebusy.query",
2486
+     "path": "freeBusy",
2487
+     "httpMethod": "POST",
2488
+     "description": "Returns free/busy information for a set of calendars.",
2489
+     "request": {
2490
+      "$ref": "FreeBusyRequest"
2491
+     },
2492
+     "response": {
2493
+      "$ref": "FreeBusyResponse"
2494
+     },
2495
+     "scopes": [
2496
+      "https://www.googleapis.com/auth/calendar",
2497
+      "https://www.googleapis.com/auth/calendar.readonly"
2498
+     ]
2499
+    }
2500
+   }
2501
+  },
2502
+  "settings": {
2503
+   "methods": {
2504
+    "get": {
2505
+     "id": "calendar.settings.get",
2506
+     "path": "users/me/settings/{setting}",
2507
+     "httpMethod": "GET",
2508
+     "description": "Returns a single user setting.",
2509
+     "parameters": {
2510
+      "setting": {
2511
+       "type": "string",
2512
+       "description": "The id of the user setting.",
2513
+       "required": true,
2514
+       "location": "path"
2515
+      }
2516
+     },
2517
+     "parameterOrder": [
2518
+      "setting"
2519
+     ],
2520
+     "response": {
2521
+      "$ref": "Setting"
2522
+     },
2523
+     "scopes": [
2524
+      "https://www.googleapis.com/auth/calendar",
2525
+      "https://www.googleapis.com/auth/calendar.readonly"
2526
+     ]
2527
+    },
2528
+    "list": {
2529
+     "id": "calendar.settings.list",
2530
+     "path": "users/me/settings",
2531
+     "httpMethod": "GET",
2532
+     "description": "Returns all user settings for the authenticated user.",
2533
+     "parameters": {
2534
+      "maxResults": {
2535
+       "type": "integer",
2536
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
2537
+       "format": "int32",
2538
+       "minimum": "1",
2539
+       "location": "query"
2540
+      },
2541
+      "pageToken": {
2542
+       "type": "string",
2543
+       "description": "Token specifying which result page to return. Optional.",
2544
+       "location": "query"
2545
+      },
2546
+      "syncToken": {
2547
+       "type": "string",
2548
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2549
+       "location": "query"
2550
+      }
2551
+     },
2552
+     "response": {
2553
+      "$ref": "Settings"
2554
+     },
2555
+     "scopes": [
2556
+      "https://www.googleapis.com/auth/calendar",
2557
+      "https://www.googleapis.com/auth/calendar.readonly"
2558
+     ],
2559
+     "supportsSubscription": true
2560
+    },
2561
+    "watch": {
2562
+     "id": "calendar.settings.watch",
2563
+     "path": "users/me/settings/watch",
2564
+     "httpMethod": "POST",
2565
+     "description": "Watch for changes to Settings resources.",
2566
+     "parameters": {
2567
+      "maxResults": {
2568
+       "type": "integer",
2569
+       "description": "Maximum number of entries returned on one result page. By default the value is 100 entries. The page size can never be larger than 250 entries. Optional.",
2570
+       "format": "int32",
2571
+       "minimum": "1",
2572
+       "location": "query"
2573
+      },
2574
+      "pageToken": {
2575
+       "type": "string",
2576
+       "description": "Token specifying which result page to return. Optional.",
2577
+       "location": "query"
2578
+      },
2579
+      "syncToken": {
2580
+       "type": "string",
2581
+       "description": "Token obtained from the nextSyncToken field returned on the last page of results from the previous list request. It makes the result of this list request contain only entries that have changed since then.\nIf the syncToken expires, the server will respond with a 410 GONE response code and the client should clear its storage and perform a full synchronization without any syncToken.\nLearn more about incremental synchronization.\nOptional. The default is to return all entries.",
2582
+       "location": "query"
2583
+      }
2584
+     },
2585
+     "request": {
2586
+      "$ref": "Channel",
2587
+      "parameterName": "resource"
2588
+     },
2589
+     "response": {
2590
+      "$ref": "Channel"
2591
+     },
2592
+     "scopes": [
2593
+      "https://www.googleapis.com/auth/calendar",
2594
+      "https://www.googleapis.com/auth/calendar.readonly"
2595
+     ],
2596
+     "supportsSubscription": true
2597
+    }
2598
+   }
2599
+  }
2600
+ }
2601
+}

BIN
spec/data_fixtures/private.key


+ 4 - 4
spec/fixtures/agents.yml

@@ -10,8 +10,8 @@ jane_website_agent:
10 10
                  :expected_update_period_in_days => 2,
11 11
                  :mode => :on_change,
12 12
                  :extract => {
13
-                     :title => {:css => "item title", :text => true},
14
-                     :url => {:css => "item link", :text => true}
13
+                     :title => {:css => "item title", :value => './/text()'},
14
+                     :url => {:css => "item link", :value => './/text()'}
15 15
                  }
16 16
                }.to_json.inspect %>
17 17
 
@@ -27,8 +27,8 @@ bob_website_agent:
27 27
                  :expected_update_period_in_days => 2,
28 28
                  :mode => :on_change,
29 29
                  :extract => {
30
-                   :url => {:css => "#comic img", :attr => "src"},
31
-                   :title => {:css => "#comic img", :attr => "title"}
30
+                   :url => {:css => "#comic img", :value => "@src"},
31
+                   :title => {:css => "#comic img", :value => "@title"}
32 32
                  }
33 33
                }.to_json.inspect %>
34 34
 

+ 68 - 15
spec/helpers/dot_helper_spec.rb

@@ -1,12 +1,6 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe DotHelper do
4
-  describe "#dot_id" do
5
-    it "properly escapes double quotaion and backslash" do
6
-      dot_id('hello\\"').should == '"hello\\\\\\""'
7
-    end
8
-  end
9
-
10 4
   describe "with example Agents" do
11 5
     class Agents::DotFoo < Agent
12 6
       default_schedule "2pm"
@@ -30,18 +24,77 @@ describe DotHelper do
30 24
     end
31 25
 
32 26
     describe "#agents_dot" do
27
+      before do
28
+        @agents = [
29
+          @foo = Agents::DotFoo.new(name: "foo").tap { |agent|
30
+            agent.user = users(:bob)
31
+            agent.save!
32
+          },
33
+
34
+          @bar1 = Agents::DotBar.new(name: "bar1").tap { |agent|
35
+            agent.user = users(:bob)
36
+            agent.sources << @foo
37
+            agent.save!
38
+          },
39
+
40
+          @bar2 = Agents::DotBar.new(name: "bar2").tap { |agent|
41
+            agent.user = users(:bob)
42
+            agent.sources << @foo
43
+            agent.propagate_immediately = true
44
+            agent.disabled = true
45
+            agent.save!
46
+          },
47
+
48
+          @bar3 = Agents::DotBar.new(name: "bar3").tap { |agent|
49
+            agent.user = users(:bob)
50
+            agent.sources << @bar2
51
+            agent.save!
52
+          },
53
+        ]
54
+      end
55
+
33 56
       it "generates a DOT script" do
34
-        @foo = Agents::DotFoo.new(:name => "foo")
35
-        @foo.user = users(:bob)
36
-        @foo.save!
57
+        agents_dot(@agents).should =~ %r{
58
+          \A
59
+          digraph \s foo \{
60
+            node \[ [^\]]+ \];
61
+            (?<foo>\w+) \[label=foo\];
62
+            \k<foo> -> (?<bar1>\w+) \[style=dashed\];
63
+            \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];
64
+            \k<bar1> \[label=bar1\];
65
+            \k<bar2> \[label="bar2 \s \(Disabled\)",style="rounded,dashed",color="\#999999",fontcolor="\#999999"\];
66
+            \k<bar2> -> (?<bar3>\w+) \[style=dashed,color="\#999999"\];
67
+            \k<bar3> \[label=bar3\];
68
+          \}
69
+          \z
70
+        }x
71
+      end
37 72
 
38
-        @bar = Agents::DotBar.new(:name => "bar")
39
-        @bar.user = users(:bob)
40
-        @bar.sources << @foo
41
-        @bar.save!
73
+      it "generates a richer DOT script" do
74
+        agents_dot(@agents, true).should =~ %r{
75
+          \A
76
+          digraph \s foo \{
77
+            node \[ [^\]]+ \];
78
+            (?<foo>\w+) \[label=foo,URL="#{Regexp.quote(agent_path(@foo))}"\];
79
+            \k<foo> -> (?<bar1>\w+) \[style=dashed\];
80
+            \k<foo> -> (?<bar2>\w+) \[color="\#999999"\];
81
+            \k<bar1> \[label=bar1,URL="#{Regexp.quote(agent_path(@bar1))}"\];
82
+            \k<bar2> \[label="bar2 \s \(Disabled\)",URL="#{Regexp.quote(agent_path(@bar2))}",style="rounded,dashed",color="\#999999",fontcolor="\#999999"\];
83
+            \k<bar2> -> (?<bar3>\w+) \[style=dashed,color="\#999999"\];
84
+            \k<bar3> \[label=bar3,URL="#{Regexp.quote(agent_path(@bar3))}"\];
85
+          \}
86
+          \z
87
+        }x
88
+      end
89
+    end
90
+  end
42 91
 
43
-        agents_dot([@foo, @bar]).should == 'digraph foo {"foo";"foo"->"bar";"bar";}'
44
-        agents_dot([@foo, @bar], true).should == 'digraph foo {"foo"[URL="/agents/%d"];"foo"->"bar";"bar"[URL="/agents/%d"];}' % [@foo.id, @bar.id]
92
+  describe DotHelper::DotDrawer do
93
+    describe "#id" do
94
+      it "properly escapes double quotaion and backslash" do
95
+        DotHelper::DotDrawer.draw(foo: "") {
96
+          id('hello\\"')
97
+        }.should == '"hello\\\\\\""'
45 98
       end
46 99
     end
47 100
   end

+ 3 - 3
spec/lib/utils_spec.rb

@@ -22,8 +22,8 @@ describe Utils do
22 22
 
23 23
       Utils.unindent("Hello\n  I am indented").should == "Hello\n  I am indented"
24 24
 
25
-      a = "        Events will have the fields you specified.  Your options look like:\n\n            {\n      \"url\": {\n        \"css\": \"#comic img\",\n        \"attr\": \"src\"\n      },\n      \"title\": {\n        \"css\": \"#comic img\",\n        \"attr\": \"title\"\n      }\n    }\"\n"
26
-      Utils.unindent(a).should == "Events will have the fields you specified.  Your options look like:\n\n    {\n      \"url\": {\n\"css\": \"#comic img\",\n\"attr\": \"src\"\n      },\n      \"title\": {\n\"css\": \"#comic img\",\n\"attr\": \"title\"\n      }\n    }\""
25
+      a = "        Events will have the fields you specified.  Your options look like:\n\n            {\n      \"url\": {\n        \"css\": \"#comic img\",\n        \"value\": \"@src\"\n      },\n      \"title\": {\n        \"css\": \"#comic img\",\n        \"value\": \"@title\"\n      }\n    }\"\n"
26
+      Utils.unindent(a).should == "Events will have the fields you specified.  Your options look like:\n\n    {\n      \"url\": {\n\"css\": \"#comic img\",\n\"value\": \"@src\"\n      },\n      \"title\": {\n\"css\": \"#comic img\",\n\"value\": \"@title\"\n      }\n    }\""
27 27
     end
28 28
   end
29 29
 
@@ -114,4 +114,4 @@ describe Utils do
114 114
       cleaned_json.should include("<\\/script>")
115 115
     end
116 116
   end
117
-end
117
+end

+ 102 - 0
spec/models/agent_spec.rb

@@ -132,6 +132,13 @@ describe Agent do
132 132
       it_behaves_like HasGuid
133 133
     end
134 134
 
135
+    describe ".short_type" do
136
+      it "returns a short name without 'Agents::'" do
137
+        Agents::SomethingSource.new.short_type.should == "SomethingSource"
138
+        Agents::CannotBeScheduled.new.short_type.should == "CannotBeScheduled"
139
+      end
140
+    end
141
+
135 142
     describe ".default_schedule" do
136 143
       it "stores the default on the class" do
137 144
         Agents::SomethingSource.default_schedule.should == "2pm"
@@ -729,3 +736,98 @@ describe Agent do
729 736
     end
730 737
   end
731 738
 end
739
+
740
+describe AgentDrop do
741
+  def interpolate(string, agent)
742
+    agent.interpolate_string(string, "agent" => agent)
743
+  end
744
+
745
+  before do
746
+    @wsa1 = Agents::WebsiteAgent.new(
747
+      name: 'XKCD',
748
+      options: {
749
+        expected_update_period_in_days: 2,
750
+        type: 'html',
751
+        url: 'http://xkcd.com/',
752
+        mode: 'on_change',
753
+        extract: {
754
+          url: { css: '#comic img', value: '@src' },
755
+          title: { css: '#comic img', value: '@alt' },
756
+        },
757
+      },
758
+      schedule: 'every_1h',
759
+      keep_events_for: 2)
760
+    @wsa1.user = users(:bob)
761
+    @wsa1.save!
762
+
763
+    @wsa2 = Agents::WebsiteAgent.new(
764
+      name: 'Dilbert',
765
+      options: {
766
+        expected_update_period_in_days: 2,
767
+        type: 'html',
768
+        url: 'http://dilbert.com/',
769
+        mode: 'on_change',
770
+        extract: {
771
+          url: { css: '[id^=strip_enlarged_] img', value: '@src' },
772
+          title: { css: '.STR_DateStrip', value: './/text()' },
773
+        },
774
+      },
775
+      schedule: 'every_12h',
776
+      keep_events_for: 2)
777
+    @wsa2.user = users(:bob)
778
+    @wsa2.save!
779
+
780
+    @efa = Agents::EventFormattingAgent.new(
781
+      name: 'Formatter',
782
+      options: {
783
+        instructions: {
784
+          message: '{{agent.name}}: {{title}} {{url}}',
785
+          agent: '{{agent.type}}',
786
+        },
787
+        mode: 'clean',
788
+        matchers: [],
789
+        skip_created_at: 'false',
790
+      },
791
+      keep_events_for: 2,
792
+      propagate_immediately: true)
793
+    @efa.user = users(:bob)
794
+    @efa.sources << @wsa1 << @wsa2
795
+    @efa.memory[:test] = 1
796
+    @efa.save!
797
+  end
798
+
799
+  it 'should be created via Agent#to_liquid' do
800
+    @wsa1.to_liquid.class.should be(AgentDrop)
801
+    @wsa2.to_liquid.class.should be(AgentDrop)
802
+    @efa.to_liquid.class.should be(AgentDrop)
803
+  end
804
+
805
+  it 'should have .type and .name' do
806
+    t = '{{agent.type}}: {{agent.name}}'
807
+    interpolate(t, @wsa1).should eq('WebsiteAgent: XKCD')
808
+    interpolate(t, @wsa2).should eq('WebsiteAgent: Dilbert')
809
+    interpolate(t, @efa).should eq('EventFormattingAgent: Formatter')
810
+  end
811
+
812
+  it 'should have .options' do
813
+    t = '{{agent.options.url}}'
814
+    interpolate(t, @wsa1).should eq('http://xkcd.com/')
815
+    interpolate(t, @wsa2).should eq('http://dilbert.com/')
816
+    interpolate('{{agent.options.instructions.message}}',
817
+                @efa).should eq('{{agent.name}}: {{title}} {{url}}')
818
+  end
819
+
820
+  it 'should have .sources' do
821
+    t = '{{agent.sources.size}}: {{agent.sources | map:"name" | join:", "}}'
822
+    interpolate(t, @wsa1).should eq('0: ')
823
+    interpolate(t, @wsa2).should eq('0: ')
824
+    interpolate(t, @efa).should eq('2: XKCD, Dilbert')
825
+  end
826
+
827
+  it 'should have .receivers' do
828
+    t = '{{agent.receivers.size}}: {{agent.receivers | map:"name" | join:", "}}'
829
+    interpolate(t, @wsa1).should eq('1: Formatter')
830
+    interpolate(t, @wsa2).should eq('1: Formatter')
831
+    interpolate(t, @efa).should eq('0: ')
832
+  end
833
+end

+ 3 - 2
spec/models/agents/email_agent_spec.rb

@@ -1,12 +1,14 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Agents::EmailAgent do
4
+  it_behaves_like EmailConcern
5
+
4 6
   def get_message_part(mail, content_type)
5 7
     mail.body.parts.find { |p| p.content_type.match content_type }.body.raw_source
6 8
   end
7 9
 
8 10
   before do
9
-    @checker = Agents::EmailAgent.new(:name => "something", :options => { :expected_receive_period_in_days => 2, :subject => "something interesting" })
11
+    @checker = Agents::EmailAgent.new(:name => "something", :options => { :expected_receive_period_in_days => "2", :subject => "something interesting" })
10 12
     @checker.user = users(:bob)
11 13
     @checker.save!
12 14
   end
@@ -54,6 +56,5 @@ describe Agents::EmailAgent do
54 56
       plain_email_text.should =~ /avehumidity/
55 57
       html_email_text.should =~ /avehumidity/
56 58
     end
57
-
58 59
   end
59 60
 end

+ 3 - 1
spec/models/agents/email_digest_agent_spec.rb

@@ -1,12 +1,14 @@
1 1
 require 'spec_helper'
2 2
 
3 3
 describe Agents::EmailDigestAgent do
4
+  it_behaves_like EmailConcern
5
+
4 6
   def get_message_part(mail, content_type)
5 7
     mail.body.parts.find { |p| p.content_type.match content_type }.body.raw_source
6 8
   end
7 9
 
8 10
   before do
9
-    @checker = Agents::EmailDigestAgent.new(:name => "something", :options => { :expected_receive_period_in_days => 2, :subject => "something interesting" })
11
+    @checker = Agents::EmailDigestAgent.new(:name => "something", :options => { :expected_receive_period_in_days => "2", :subject => "something interesting" })
10 12
     @checker.user = users(:bob)
11 13
     @checker.save!
12 14
   end

+ 5 - 17
spec/models/agents/event_formatting_agent_spec.rb

@@ -7,7 +7,8 @@ describe Agents::EventFormattingAgent do
7 7
         :options => {
8 8
             :instructions => {
9 9
                 :message => "Received {{content.text}} from {{content.name}} .",
10
-                :subject => "Weather looks like {{conditions}} according to the forecast at {{pretty_date.time}}"
10
+                :subject => "Weather looks like {{conditions}} according to the forecast at {{pretty_date.time}}",
11
+                :agent => "{{agent.type}}",
11 12
             },
12 13
             :mode => "clean",
13 14
             :matchers => [
@@ -17,7 +18,6 @@ describe Agents::EventFormattingAgent do
17 18
                     :to => "pretty_date",
18 19
                 },
19 20
             ],
20
-            :skip_agent => "false",
21 21
             :skip_created_at => "false"
22 22
         }
23 23
     }
@@ -53,14 +53,6 @@ describe Agents::EventFormattingAgent do
53 53
       Event.last.payload[:content].should_not == nil
54 54
     end
55 55
 
56
-    it "should accept skip_agent" do
57
-      @checker.receive([@event])
58
-      Event.last.payload[:agent].should == "WeatherAgent"
59
-      @checker.options[:skip_agent] = "true"
60
-      @checker.receive([@event])
61
-      Event.last.payload[:agent].should == nil
62
-    end
63
-
64 56
     it "should accept skip_created_at" do
65 57
       @checker.receive([@event])
66 58
       Event.last.payload[:created_at].should_not == nil
@@ -69,12 +61,13 @@ describe Agents::EventFormattingAgent do
69 61
       Event.last.payload[:created_at].should == nil
70 62
     end
71 63
 
72
-    it "should handle JSONPaths in instructions" do
64
+    it "should handle Liquid templating in instructions" do
73 65
       @checker.receive([@event])
74 66
       Event.last.payload[:message].should == "Received Some Lorem Ipsum from somevalue ."
67
+      Event.last.payload[:agent].should == "WeatherAgent"
75 68
     end
76 69
 
77
-    it "should handle matchers and JSONPaths in instructions" do
70
+    it "should handle matchers and Liquid templating in instructions" do
78 71
       @checker.receive([@event])
79 72
       Event.last.payload[:subject].should == "Weather looks like someothervalue according to the forecast at 10:00 PM EST"
80 73
     end
@@ -152,11 +145,6 @@ describe Agents::EventFormattingAgent do
152 145
       @checker.should_not be_valid
153 146
     end
154 147
 
155
-    it "should validate presence of skip_agent" do
156
-      @checker.options[:skip_agent] = ""
157
-      @checker.should_not be_valid
158
-    end
159
-
160 148
     it "should validate presence of skip_created_at" do
161 149
       @checker.options[:skip_created_at] = ""
162 150
       @checker.should_not be_valid

+ 43 - 0
spec/models/agents/google_calendar_publish_agent_spec.rb

@@ -0,0 +1,43 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::GoogleCalendarPublishAgent, :vcr do
4
+  before do
5
+    @valid_params = {
6
+        'expected_update_period_in_days' => "10",
7
+        'calendar_id' => 'sqv39gj35tc837gdns1g4d81cg@group.calendar.google.com',
8
+        'google' => {
9
+          'key_file' => File.dirname(__FILE__) + '/../../data_fixtures/private.key',
10
+          'key_secret' => 'notasecret',
11
+          'service_account_email' => '1029936966326-ncjd7776pcspc98hsg82gsb56t3217ef@developer.gserviceaccount.com'
12
+        }
13
+      }
14
+    @checker = Agents::GoogleCalendarPublishAgent.new(:name => "somename", :options => @valid_params)
15
+    @checker.user = users(:jane)
16
+    @checker.save!
17
+  end
18
+
19
+  describe '#receive' do
20
+    it 'should publish any payload it receives' do
21
+      event1 = Event.new
22
+      event1.agent = agents(:bob_manual_event_agent)
23
+      event1.payload = {
24
+        'message' => { 
25
+          'visibility' => 'default',
26
+          'summary' => "Awesome event",
27
+          'description' => "An example event with text. Pro tip: DateTimes are in RFC3339",
28
+          'end' => {
29
+            'dateTime' => '2014-10-02T11:00:00-05:00'
30
+          },
31
+          'start' => {
32
+            'dateTime' => '2014-10-02T10:00:00-05:00'
33
+          }
34
+        }
35
+      }
36
+      event1.save!
37
+
38
+      @checker.receive([event1])
39
+
40
+      @checker.events.count.should eq(1)
41
+    end
42
+  end
43
+end

+ 34 - 18
spec/models/agents/imap_folder_agent_spec.rb

@@ -24,7 +24,7 @@ describe Agents::ImapFolderAgent do
24 24
         end
25 25
 
26 26
         def uidvalidity
27
-          '100'
27
+          100
28 28
         end
29 29
 
30 30
         def has_attachment?
@@ -53,7 +53,15 @@ describe Agents::ImapFolderAgent do
53 53
       ]
54 54
 
55 55
       stub(@checker).each_unread_mail.returns { |yielder|
56
-        @mails.each(&yielder)
56
+        seen = @checker.lastseen
57
+        notified = @checker.notified
58
+        @mails.each_with_object(notified) { |mail|
59
+          yielder[mail, notified]
60
+          seen[mail.uidvalidity] = mail.uid
61
+        }
62
+        @checker.lastseen = seen
63
+        @checker.notified = notified
64
+        nil
57 65
       }
58 66
 
59 67
       @payloads = [
@@ -110,11 +118,19 @@ describe Agents::ImapFolderAgent do
110 118
       end
111 119
 
112 120
       it 'should validate the boolean fields' do
113
-        @checker.options['ssl'] = false
114
-        @checker.should be_valid
121
+        %w[ssl mark_as_read].each do |key|
122
+          @checker.options[key] = 1
123
+          @checker.should_not be_valid
115 124
 
116
-        @checker.options['ssl'] = 'true'
117
-        @checker.should_not be_valid
125
+          @checker.options[key] = false
126
+          @checker.should be_valid
127
+
128
+          @checker.options[key] = 'true'
129
+          @checker.should be_valid
130
+
131
+          @checker.options[key] = ''
132
+          @checker.should be_valid
133
+        end
118 134
       end
119 135
 
120 136
       it 'should validate regexp conditions' do
@@ -139,9 +155,9 @@ describe Agents::ImapFolderAgent do
139 155
     describe '#check' do
140 156
       it 'should check for mails and save memory' do
141 157
         lambda { @checker.check }.should change { Event.count }.by(2)
142
-        @checker.memory['notified'].sort.should == @mails.map(&:message_id).sort
143
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
144
-          (seen[mail.uidvalidity] ||= []) << mail.uid
158
+        @checker.notified.sort.should == @mails.map(&:message_id).sort
159
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
160
+          seen[mail.uidvalidity] = mail.uid
145 161
         }
146 162
 
147 163
         Event.last(2).map(&:payload) == @payloads
@@ -153,9 +169,9 @@ describe Agents::ImapFolderAgent do
153 169
         @checker.options['conditions']['to'] = 'John.Doe@*'
154 170
 
155 171
         lambda { @checker.check }.should change { Event.count }.by(1)
156
-        @checker.memory['notified'].sort.should == [@mails.first.message_id]
157
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
158
-          (seen[mail.uidvalidity] ||= []) << mail.uid
172
+        @checker.notified.sort.should == [@mails.first.message_id]
173
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
174
+          seen[mail.uidvalidity] = mail.uid
159 175
         }
160 176
 
161 177
         Event.last.payload.should == @payloads.first
@@ -170,9 +186,9 @@ describe Agents::ImapFolderAgent do
170 186
         )
171 187
 
172 188
         lambda { @checker.check }.should change { Event.count }.by(1)
173
-        @checker.memory['notified'].sort.should == [@mails.last.message_id]
174
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
175
-          (seen[mail.uidvalidity] ||= []) << mail.uid
189
+        @checker.notified.sort.should == [@mails.last.message_id]
190
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
191
+          seen[mail.uidvalidity] = mail.uid
176 192
         }
177 193
 
178 194
         Event.last.payload.should == @payloads.last.update(
@@ -208,9 +224,9 @@ describe Agents::ImapFolderAgent do
208 224
         )
209 225
 
210 226
         lambda { @checker.check }.should_not change { Event.count }
211
-        @checker.memory['notified'].sort.should == []
212
-        @checker.memory['seen'].should == @mails.each_with_object({}) { |mail, seen|
213
-          (seen[mail.uidvalidity] ||= []) << mail.uid
227
+        @checker.notified.sort.should == []
228
+        @checker.lastseen.should == @mails.each_with_object(@checker.make_seen) { |mail, seen|
229
+          seen[mail.uidvalidity] = mail.uid
214 230
         }
215 231
       end
216 232
 

+ 74 - 17
spec/models/agents/post_agent_spec.rb

@@ -25,11 +25,25 @@ describe Agents::PostAgent do
25 25
         'somekey' => 'value'
26 26
       }
27 27
     }
28
+    @requests = 0
29
+    @sent_requests = { Net::HTTP::Get => [], Net::HTTP::Post => [], Net::HTTP::Put => [], Net::HTTP::Delete => [], Net::HTTP::Patch => [] }
28 30
 
29
-    @sent_posts = []
30
-    @sent_gets = []
31
-    stub.any_instance_of(Agents::PostAgent).post_data { |data| @sent_posts << data }
32
-    stub.any_instance_of(Agents::PostAgent).get_data { |data| @sent_gets << data }
31
+    stub.any_instance_of(Agents::PostAgent).post_data { |data, payload, type| @requests += 1; @sent_requests[type] << data }
32
+    stub.any_instance_of(Agents::PostAgent).get_data { |data, payload| @requests += 1; @sent_requests[Net::HTTP::Get] << data }
33
+  end
34
+
35
+  describe "making requests" do
36
+    it "can make requests of each type" do
37
+      { 'get' => Net::HTTP::Get, 'put' => Net::HTTP::Put,
38
+        'post' => Net::HTTP::Post, 'patch' => Net::HTTP::Patch,
39
+        'delete' => Net::HTTP::Delete }.each.with_index do |(verb, type), index|
40
+        @checker.options['method'] = verb
41
+        @checker.should be_valid
42
+        @checker.check
43
+        @requests.should == index + 1
44
+        @sent_requests[type].length.should == 1
45
+      end
46
+    end
33 47
   end
34 48
 
35 49
   describe "#receive" do
@@ -45,11 +59,11 @@ describe Agents::PostAgent do
45 59
       lambda {
46 60
         lambda {
47 61
           @checker.receive([@event, event1])
48
-        }.should change { @sent_posts.length }.by(2)
49
-      }.should_not change { @sent_gets.length }
62
+        }.should change { @sent_requests[Net::HTTP::Post].length }.by(2)
63
+      }.should_not change { @sent_requests[Net::HTTP::Get].length }
50 64
 
51
-      @sent_posts[0].should == @event.payload.merge('default' => 'value')
52
-      @sent_posts[1].should == event1.payload
65
+      @sent_requests[Net::HTTP::Post][0].should == @event.payload.merge('default' => 'value')
66
+      @sent_requests[Net::HTTP::Post][1].should == event1.payload
53 67
     end
54 68
 
55 69
     it "can make GET requests" do
@@ -58,10 +72,19 @@ describe Agents::PostAgent do
58 72
       lambda {
59 73
         lambda {
60 74
           @checker.receive([@event])
61
-        }.should change { @sent_gets.length }.by(1)
62
-      }.should_not change { @sent_posts.length }
75
+        }.should change { @sent_requests[Net::HTTP::Get].length }.by(1)
76
+      }.should_not change { @sent_requests[Net::HTTP::Post].length }
77
+
78
+      @sent_requests[Net::HTTP::Get][0].should == @event.payload.merge('default' => 'value')
79
+    end
63 80
 
64
-      @sent_gets[0].should == @event.payload.merge('default' => 'value')
81
+    it "can skip merging the incoming event when no_merge is set, but it still interpolates" do
82
+      @checker.options['no_merge'] = 'true'
83
+      @checker.options['payload'] = {
84
+        'key' => 'it said: {{ someotherkey.somekey }}'
85
+      }
86
+      @checker.receive([@event])
87
+      @sent_requests[Net::HTTP::Post].first.should == { 'key' => 'it said: value' }
65 88
     end
66 89
   end
67 90
 
@@ -69,9 +92,9 @@ describe Agents::PostAgent do
69 92
     it "sends options['payload'] as a POST request" do
70 93
       lambda {
71 94
         @checker.check
72
-      }.should change { @sent_posts.length }.by(1)
95
+      }.should change { @sent_requests[Net::HTTP::Post].length }.by(1)
73 96
 
74
-      @sent_posts[0].should == @checker.options['payload']
97
+      @sent_requests[Net::HTTP::Post][0].should == @checker.options['payload']
75 98
     end
76 99
 
77 100
     it "sends options['payload'] as a GET request" do
@@ -79,10 +102,10 @@ describe Agents::PostAgent do
79 102
       lambda {
80 103
         lambda {
81 104
           @checker.check
82
-        }.should change { @sent_gets.length }.by(1)
83
-      }.should_not change { @sent_posts.length }
105
+        }.should change { @sent_requests[Net::HTTP::Get].length }.by(1)
106
+      }.should_not change { @sent_requests[Net::HTTP::Post].length }
84 107
 
85
-      @sent_gets[0].should == @checker.options['payload']
108
+      @sent_requests[Net::HTTP::Get][0].should == @checker.options['payload']
86 109
     end
87 110
   end
88 111
 
@@ -112,7 +135,7 @@ describe Agents::PostAgent do
112 135
       @checker.should_not be_valid
113 136
     end
114 137
 
115
-    it "should validate method as post or get, defaulting to post" do
138
+    it "should validate method as post, get, put, patch, or delete, defaulting to post" do
116 139
       @checker.options['method'] = ""
117 140
       @checker.method.should == "post"
118 141
       @checker.should be_valid
@@ -125,11 +148,35 @@ describe Agents::PostAgent do
125 148
       @checker.method.should == "get"
126 149
       @checker.should be_valid
127 150
 
151
+      @checker.options['method'] = "patch"
152
+      @checker.method.should == "patch"
153
+      @checker.should be_valid
154
+
128 155
       @checker.options['method'] = "wut"
129 156
       @checker.method.should == "wut"
130 157
       @checker.should_not be_valid
131 158
     end
132 159
 
160
+    it "should validate that no_merge is 'true' or 'false', if present" do
161
+      @checker.options['no_merge'] = ""
162
+      @checker.should be_valid
163
+
164
+      @checker.options['no_merge'] = "true"
165
+      @checker.should be_valid
166
+
167
+      @checker.options['no_merge'] = "false"
168
+      @checker.should be_valid
169
+
170
+      @checker.options['no_merge'] = false
171
+      @checker.should be_valid
172
+
173
+      @checker.options['no_merge'] = true
174
+      @checker.should be_valid
175
+
176
+      @checker.options['no_merge'] = 'blarg'
177
+      @checker.should_not be_valid
178
+    end
179
+
133 180
     it "should validate payload as a hash, if present" do
134 181
       @checker.options['payload'] = ""
135 182
       @checker.should be_valid
@@ -178,7 +225,17 @@ describe Agents::PostAgent do
178 225
     it "just returns the post_uri when no params are given" do
179 226
       @checker.options['post_url'] = "http://example.com/a/path?existing_param=existing_value"
180 227
       uri = @checker.generate_uri
228
+      uri.host.should == 'example.com'
229
+      uri.scheme.should == 'http'
181 230
       uri.request_uri.should == "/a/path?existing_param=existing_value"
182 231
     end
232
+
233
+    it "interpolates when receiving a payload" do
234
+      @checker.options['post_url'] = "https://{{ domain }}/{{ variable }}?existing_param=existing_value"
235
+      uri = @checker.generate_uri({ "some_param" => "some_value", "another_param" => "another_value" }, { 'domain' => 'google.com', 'variable' => 'a_variable' })
236
+      uri.request_uri.should == "/a_variable?existing_param=existing_value&some_param=some_value&another_param=another_value"
237
+      uri.host.should == 'google.com'
238
+      uri.scheme.should == 'https'
239
+    end
183 240
   end
184 241
 end

+ 81 - 0
spec/models/agents/rss_agent_spec.rb

@@ -0,0 +1,81 @@
1
+require 'spec_helper'
2
+
3
+describe Agents::RssAgent do
4
+  before do
5
+    @valid_options = {
6
+      'expected_update_period_in_days' => "2",
7
+      'url' => "https://github.com/cantino/huginn/commits/master.atom",
8
+    }
9
+
10
+    stub_request(:any, /github.com/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/github_rss.atom")), :status => 200)
11
+  end
12
+
13
+  let(:agent) do
14
+    _agent = Agents::RssAgent.new(:name => "github rss feed", :options => @valid_options)
15
+    _agent.user = users(:bob)
16
+    _agent.save!
17
+    _agent
18
+  end
19
+
20
+  it_behaves_like WebRequestConcern
21
+
22
+  describe "validations" do
23
+    it "should validate the presence of url" do
24
+      agent.options['url'] = "http://google.com"
25
+      agent.should be_valid
26
+
27
+      agent.options['url'] = ""
28
+      agent.should_not be_valid
29
+
30
+      agent.options['url'] = nil
31
+      agent.should_not be_valid
32
+    end
33
+
34
+    it "should validate the presence and numericality of expected_update_period_in_days" do
35
+      agent.options['expected_update_period_in_days'] = "5"
36
+      agent.should be_valid
37
+
38
+      agent.options['expected_update_period_in_days'] = "wut?"
39
+      agent.should_not be_valid
40
+
41
+      agent.options['expected_update_period_in_days'] = 0
42
+      agent.should_not be_valid
43
+
44
+      agent.options['expected_update_period_in_days'] = nil
45
+      agent.should_not be_valid
46
+
47
+      agent.options['expected_update_period_in_days'] = ""
48
+      agent.should_not be_valid
49
+    end
50
+  end
51
+
52
+  describe "emitting RSS events" do
53
+    it "should emit items as events" do
54
+      lambda {
55
+        agent.check
56
+      }.should change { agent.events.count }.by(20)
57
+    end
58
+
59
+    it "should track ids and not re-emit the same item when seen again" do
60
+      agent.check
61
+      agent.memory['seen_ids'].should == agent.events.map {|e| e.payload['id'] }
62
+
63
+      newest_id = agent.memory['seen_ids'][0]
64
+      agent.events.first.payload['id'].should == newest_id
65
+      agent.memory['seen_ids'] = agent.memory['seen_ids'][1..-1] # forget the newest id
66
+
67
+      lambda {
68
+        agent.check
69
+      }.should change { agent.events.count }.by(1)
70
+
71
+      agent.events.first.payload['id'].should == newest_id
72
+      agent.memory['seen_ids'][0].should == newest_id
73
+    end
74
+
75
+    it "should truncate the seen_ids in memory at 500 items" do
76
+      agent.memory['seen_ids'] = ['x'] * 490
77
+      agent.check
78
+      agent.memory['seen_ids'].length.should == 500
79
+    end
80
+  end
81
+end

+ 85 - 60
spec/models/agents/website_agent_spec.rb

@@ -4,23 +4,25 @@ describe Agents::WebsiteAgent do
4 4
   describe "checking without basic auth" do
5 5
     before do
6 6
       stub_request(:any, /xkcd/).to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
7
-      @site = {
7
+      @valid_options = {
8 8
         'name' => "XKCD",
9
-        'expected_update_period_in_days' => 2,
9
+        'expected_update_period_in_days' => "2",
10 10
         'type' => "html",
11 11
         'url' => "http://xkcd.com",
12 12
         'mode' => 'on_change',
13 13
         'extract' => {
14
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
15
-          'title' => { 'css' => "#comic img", 'attr' => "alt" },
16
-          'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
14
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
15
+          'title' => { 'css' => "#comic img", 'value' => "@alt" },
16
+          'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
17 17
         }
18 18
       }
19
-      @checker = Agents::WebsiteAgent.new(:name => "xkcd", :options => @site, :keep_events_for => 2)
19
+      @checker = Agents::WebsiteAgent.new(:name => "xkcd", :options => @valid_options, :keep_events_for => 2)
20 20
       @checker.user = users(:bob)
21 21
       @checker.save!
22 22
     end
23 23
 
24
+    it_behaves_like WebRequestConcern
25
+
24 26
     describe "validations" do
25 27
       before do
26 28
         @checker.should be_valid
@@ -42,20 +44,6 @@ describe Agents::WebsiteAgent do
42 44
         @checker.should be_valid
43 45
       end
44 46
 
45
-      it "should validate headers" do
46
-        @checker.options['headers'] = "blah"
47
-        @checker.should_not be_valid
48
-
49
-        @checker.options['headers'] = ""
50
-        @checker.should be_valid
51
-
52
-        @checker.options['headers'] = {}
53
-        @checker.should be_valid
54
-
55
-        @checker.options['headers'] = { 'foo' => 'bar' }
56
-        @checker.should be_valid
57
-      end
58
-
59 47
       it "should validate mode" do
60 48
         @checker.options['mode'] = "nonsense"
61 49
         @checker.should_not be_valid
@@ -97,16 +85,16 @@ describe Agents::WebsiteAgent do
97 85
 
98 86
       it "should always save events when in :all mode" do
99 87
         lambda {
100
-          @site['mode'] = 'all'
101
-          @checker.options = @site
88
+          @valid_options['mode'] = 'all'
89
+          @checker.options = @valid_options
102 90
           @checker.check
103 91
           @checker.check
104 92
         }.should change { Event.count }.by(2)
105 93
       end
106 94
 
107 95
       it "should take uniqueness_look_back into account during deduplication" do
108
-        @site['mode'] = 'all'
109
-        @checker.options = @site
96
+        @valid_options['mode'] = 'all'
97
+        @checker.options = @valid_options
110 98
         @checker.check
111 99
         @checker.check
112 100
         event = Event.last
@@ -114,47 +102,47 @@ describe Agents::WebsiteAgent do
114 102
         event.save
115 103
 
116 104
         lambda {
117
-          @site['mode'] = 'on_change'
118
-          @site['uniqueness_look_back'] = 2
119
-          @checker.options = @site
105
+          @valid_options['mode'] = 'on_change'
106
+          @valid_options['uniqueness_look_back'] = 2
107
+          @checker.options = @valid_options
120 108
           @checker.check
121 109
         }.should_not change { Event.count }
122 110
 
123 111
         lambda {
124
-          @site['mode'] = 'on_change'
125
-          @site['uniqueness_look_back'] = 1
126
-          @checker.options = @site
112
+          @valid_options['mode'] = 'on_change'
113
+          @valid_options['uniqueness_look_back'] = 1
114
+          @checker.options = @valid_options
127 115
           @checker.check
128 116
         }.should change { Event.count }.by(1)
129 117
       end
130 118
 
131 119
       it "should log an error if the number of results for a set of extraction patterns differs" do
132
-        @site['extract']['url']['css'] = "div"
133
-        @checker.options = @site
120
+        @valid_options['extract']['url']['css'] = "div"
121
+        @checker.options = @valid_options
134 122
         @checker.check
135 123
         @checker.logs.first.message.should =~ /Got an uneven number of matches/
136 124
       end
137 125
 
138 126
       it "should accept an array for url" do
139
-        @site['url'] = ["http://xkcd.com/1/", "http://xkcd.com/2/"]
140
-        @checker.options = @site
127
+        @valid_options['url'] = ["http://xkcd.com/1/", "http://xkcd.com/2/"]
128
+        @checker.options = @valid_options
141 129
         lambda { @checker.save! }.should_not raise_error;
142 130
         lambda { @checker.check }.should_not raise_error;
143 131
       end
144 132
 
145 133
       it "should parse events from all urls in array" do
146 134
         lambda {
147
-          @site['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
148
-          @site['mode'] = 'all'
149
-          @checker.options = @site
135
+          @valid_options['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
136
+          @valid_options['mode'] = 'all'
137
+          @checker.options = @valid_options
150 138
           @checker.check
151 139
         }.should change { Event.count }.by(2)
152 140
       end
153 141
 
154 142
       it "should follow unique rules when parsing array of urls" do
155 143
         lambda {
156
-          @site['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
157
-          @checker.options = @site
144
+          @valid_options['url'] = ["http://xkcd.com/", "http://xkcd.com/"]
145
+          @checker.options = @valid_options
158 146
           @checker.check
159 147
         }.should change { Event.count }.by(1)
160 148
       end
@@ -170,7 +158,7 @@ describe Agents::WebsiteAgent do
170 158
           }, :status => 200)
171 159
         site = {
172 160
           'name' => "Some JSON Response",
173
-          'expected_update_period_in_days' => 2,
161
+          'expected_update_period_in_days' => "2",
174 162
           'type' => "json",
175 163
           'url' => "http://no-encoding.example.com",
176 164
           'mode' => 'on_change',
@@ -197,7 +185,7 @@ describe Agents::WebsiteAgent do
197 185
           }, :status => 200)
198 186
         site = {
199 187
           'name' => "Some JSON Response",
200
-          'expected_update_period_in_days' => 2,
188
+          'expected_update_period_in_days' => "2",
201 189
           'type' => "json",
202 190
           'url' => "http://wrong-encoding.example.com",
203 191
           'mode' => 'on_change',
@@ -248,11 +236,11 @@ describe Agents::WebsiteAgent do
248 236
       end
249 237
 
250 238
       it "parses XPath" do
251
-        @site['extract'].each { |key, value|
239
+        @valid_options['extract'].each { |key, value|
252 240
           value.delete('css')
253 241
           value['xpath'] = "//*[@id='comic']//img"
254 242
         }
255
-        @checker.options = @site
243
+        @checker.options = @valid_options
256 244
         @checker.check
257 245
         event = Event.last
258 246
         event.payload['url'].should == "http://imgs.xkcd.com/comics/evolving.png"
@@ -263,13 +251,12 @@ describe Agents::WebsiteAgent do
263 251
       it "should turn relative urls to absolute" do
264 252
         rel_site = {
265 253
           'name' => "XKCD",
266
-          'expected_update_period_in_days' => 2,
254
+          'expected_update_period_in_days' => "2",
267 255
           'type' => "html",
268 256
           'url' => "http://xkcd.com",
269 257
           'mode' => "on_change",
270 258
           'extract' => {
271
-            'url' => {'css' => "#topLeft a", 'attr' => "href"},
272
-            'title' => {'css' => "#topLeft a", 'text' => "true"}
259
+            'url' => {'css' => "#topLeft a", 'value' => "@href"},
273 260
           }
274 261
         }
275 262
         rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
@@ -280,6 +267,44 @@ describe Agents::WebsiteAgent do
280 267
         event.payload['url'].should == "http://xkcd.com/about"
281 268
       end
282 269
 
270
+      it "should return an integer value if XPath evaluates to one" do
271
+        rel_site = {
272
+          'name' => "XKCD",
273
+          'expected_update_period_in_days' => 2,
274
+          'type' => "html",
275
+          'url' => "http://xkcd.com",
276
+          'mode' => "on_change",
277
+          'extract' => {
278
+            'num_links' => {'css' => "#comicLinks", 'value' => "count(./a)"}
279
+          }
280
+        }
281
+        rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
282
+        rel.user = users(:bob)
283
+        rel.save!
284
+        rel.check
285
+        event = Event.last
286
+        event.payload['num_links'].should == "9"
287
+      end
288
+
289
+      it "should return all texts concatenated if XPath returns many text nodes" do
290
+        rel_site = {
291
+          'name' => "XKCD",
292
+          'expected_update_period_in_days' => 2,
293
+          'type' => "html",
294
+          'url' => "http://xkcd.com",
295
+          'mode' => "on_change",
296
+          'extract' => {
297
+            'slogan' => {'css' => "#slogan", 'value' => ".//text()"}
298
+          }
299
+        }
300
+        rel = Agents::WebsiteAgent.new(:name => "xkcd", :options => rel_site)
301
+        rel.user = users(:bob)
302
+        rel.save!
303
+        rel.check
304
+        event = Event.last
305
+        event.payload['slogan'].should == "A webcomic of romance, sarcasm, math, and language."
306
+      end
307
+
283 308
       describe "JSON" do
284 309
         it "works with paths" do
285 310
           json = {
@@ -291,7 +316,7 @@ describe Agents::WebsiteAgent do
291 316
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
292 317
           site = {
293 318
             'name' => "Some JSON Response",
294
-            'expected_update_period_in_days' => 2,
319
+            'expected_update_period_in_days' => "2",
295 320
             'type' => "json",
296 321
             'url' => "http://json-site.com",
297 322
             'mode' => 'on_change',
@@ -322,7 +347,7 @@ describe Agents::WebsiteAgent do
322 347
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
323 348
           site = {
324 349
             'name' => "Some JSON Response",
325
-            'expected_update_period_in_days' => 2,
350
+            'expected_update_period_in_days' => "2",
326 351
             'type' => "json",
327 352
             'url' => "http://json-site.com",
328 353
             'mode' => 'on_change',
@@ -358,7 +383,7 @@ describe Agents::WebsiteAgent do
358 383
           stub_request(:any, /json-site/).to_return(:body => json.to_json, :status => 200)
359 384
           site = {
360 385
             'name' => "Some JSON Response",
361
-            'expected_update_period_in_days' => 2,
386
+            'expected_update_period_in_days' => "2",
362 387
             'type' => "json",
363 388
             'url' => "http://json-site.com",
364 389
             'mode' => 'on_change'
@@ -382,7 +407,7 @@ describe Agents::WebsiteAgent do
382 407
         @event.payload = { 'url' => "http://xkcd.com" }
383 408
 
384 409
         lambda {
385
-          @checker.options = @site
410
+          @checker.options = @valid_options
386 411
           @checker.receive([@event])
387 412
         }.should change { Event.count }.by(1)
388 413
       end
@@ -394,20 +419,20 @@ describe Agents::WebsiteAgent do
394 419
       stub_request(:any, /example/).
395 420
         with(headers: { 'Authorization' => "Basic #{['user:pass'].pack('m').chomp}" }).
396 421
         to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
397
-      @site = {
422
+      @valid_options = {
398 423
         'name' => "XKCD",
399
-        'expected_update_period_in_days' => 2,
424
+        'expected_update_period_in_days' => "2",
400 425
         'type' => "html",
401 426
         'url' => "http://www.example.com",
402 427
         'mode' => 'on_change',
403 428
         'extract' => {
404
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
405
-          'title' => { 'css' => "#comic img", 'attr' => "alt" },
406
-          'hovertext' => { 'css' => "#comic img", 'attr' => "title" }
429
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
430
+          'title' => { 'css' => "#comic img", 'value' => "@alt" },
431
+          'hovertext' => { 'css' => "#comic img", 'value' => "@title" }
407 432
         },
408 433
         'basic_auth' => "user:pass"
409 434
       }
410
-      @checker = Agents::WebsiteAgent.new(:name => "auth", :options => @site)
435
+      @checker = Agents::WebsiteAgent.new(:name => "auth", :options => @valid_options)
411 436
       @checker.user = users(:bob)
412 437
       @checker.save!
413 438
     end
@@ -425,18 +450,18 @@ describe Agents::WebsiteAgent do
425 450
       stub_request(:any, /example/).
426 451
         with(headers: { 'foo' => 'bar', 'user_agent' => /Faraday/ }).
427 452
         to_return(:body => File.read(Rails.root.join("spec/data_fixtures/xkcd.html")), :status => 200)
428
-      @site = {
453
+      @valid_options = {
429 454
         'name' => "XKCD",
430
-        'expected_update_period_in_days' => 2,
455
+        'expected_update_period_in_days' => "2",
431 456
         'type' => "html",
432 457
         'url' => "http://www.example.com",
433 458
         'mode' => 'on_change',
434 459
         'headers' => { 'foo' => 'bar' },
435 460
         'extract' => {
436
-          'url' => { 'css' => "#comic img", 'attr' => "src" },
461
+          'url' => { 'css' => "#comic img", 'value' => "@src" },
437 462
         }
438 463
       }
439
-      @checker = Agents::WebsiteAgent.new(:name => "ua", :options => @site)
464
+      @checker = Agents::WebsiteAgent.new(:name => "ua", :options => @valid_options)
440 465
       @checker.user = users(:bob)
441 466
       @checker.save!
442 467
     end

+ 36 - 0
spec/models/event_spec.rb

@@ -76,3 +76,39 @@ describe Event do
76 76
     end
77 77
   end
78 78
 end
79
+
80
+describe EventDrop do
81
+  def interpolate(string, event)
82
+    event.agent.interpolate_string(string, event.to_liquid)
83
+  end
84
+
85
+  before do
86
+    @event = Event.new
87
+    @event.agent = agents(:jane_weather_agent)
88
+    @event.payload = {
89
+      'title' => 'some title',
90
+      'url' => 'http://some.site.example.org/',
91
+    }
92
+    @event.save!
93
+  end
94
+
95
+  it 'should be created via Agent#to_liquid' do
96
+    @event.to_liquid.class.should be(EventDrop)
97
+  end
98
+
99
+  it 'should have attributes of its payload' do
100
+    t = '{{title}}: {{url}}'
101
+    interpolate(t, @event).should eq('some title: http://some.site.example.org/')
102
+  end
103
+
104
+  it 'should be iteratable' do
105
+    # to_liquid returns self
106
+    t = "{% for pair in to_liquid %}{{pair | join:':' }}\n{% endfor %}"
107
+    interpolate(t, @event).should eq("title:some title\nurl:http://some.site.example.org/\n")
108
+  end
109
+
110
+  it 'should have agent' do
111
+    t = '{{agent.name}}'
112
+    interpolate(t, @event).should eq('SF Weather')
113
+  end
114
+end

+ 88 - 0
spec/support/shared_examples/email_concern.rb

@@ -0,0 +1,88 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for EmailConcern do
4
+  let(:valid_options) {
5
+    {
6
+      :subject => "hello!",
7
+      :expected_receive_period_in_days => "2"
8
+    }
9
+  }
10
+
11
+  let(:agent) do
12
+    _agent = described_class.new(:name => "some email agent", :options => valid_options)
13
+    _agent.user = users(:jane)
14
+    _agent
15
+  end
16
+
17
+  describe "validations" do
18
+    it "should be valid" do
19
+      agent.should be_valid
20
+    end
21
+
22
+    it "should validate the presence of 'subject'" do
23
+      agent.options['subject'] = ''
24
+      agent.should_not be_valid
25
+
26
+      agent.options['subject'] = nil
27
+      agent.should_not be_valid
28
+    end
29
+
30
+    it "should validate the presence of 'expected_receive_period_in_days'" do
31
+      agent.options['expected_receive_period_in_days'] = ''
32
+      agent.should_not be_valid
33
+
34
+      agent.options['expected_receive_period_in_days'] = nil
35
+      agent.should_not be_valid
36
+    end
37
+
38
+    it "should validate that recipients, when provided, is one or more valid email addresses" do
39
+      agent.options['recipients'] = ''
40
+      agent.should be_valid
41
+
42
+      agent.options['recipients'] = nil
43
+      agent.should be_valid
44
+
45
+      agent.options['recipients'] = 'bob@example.com'
46
+      agent.should be_valid
47
+
48
+      agent.options['recipients'] = ['bob@example.com']
49
+      agent.should be_valid
50
+
51
+      agent.options['recipients'] = ['bob@example.com', 'jane@example.com']
52
+      agent.should be_valid
53
+
54
+      agent.options['recipients'] = ['bob@example.com', 'example.com']
55
+      agent.should_not be_valid
56
+
57
+      agent.options['recipients'] = ['hi!']
58
+      agent.should_not be_valid
59
+
60
+      agent.options['recipients'] = { :foo => "bar" }
61
+      agent.should_not be_valid
62
+
63
+      agent.options['recipients'] = "wut"
64
+      agent.should_not be_valid
65
+    end
66
+  end
67
+
68
+  describe "#recipients" do
69
+    it "defaults to the user's email address" do
70
+      agent.recipients.should == [users(:jane).email]
71
+    end
72
+
73
+    it "wraps a string with an array" do
74
+      agent.options['recipients'] = 'bob@bob.com'
75
+      agent.recipients.should == ['bob@bob.com']
76
+    end
77
+
78
+    it "handles an array" do
79
+      agent.options['recipients'] = ['bob@bob.com', 'jane@jane.com']
80
+      agent.recipients.should == ['bob@bob.com', 'jane@jane.com']
81
+    end
82
+
83
+    it "interpolates" do
84
+      agent.options['recipients'] = "{{ username }}@{{ domain }}"
85
+      agent.recipients('username' => 'bob', 'domain' => 'example.com').should == ["bob@example.com"]
86
+    end
87
+  end
88
+end

+ 5 - 5
spec/support/shared_examples/liquid_interpolatable.rb

@@ -20,7 +20,7 @@ shared_examples_for LiquidInterpolatable do
20 20
 
21 21
   describe "interpolating liquid templates" do
22 22
     it "should work" do
23
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
23
+      @checker.interpolate_options(@checker.options, @event).should == {
24 24
           "normal" => "just some normal text",
25 25
           "variable" => "hello",
26 26
           "text" => "Some test with an embedded hello",
@@ -30,7 +30,7 @@ shared_examples_for LiquidInterpolatable do
30 30
 
31 31
     it "should work with arrays", focus: true do
32 32
       @checker.options = {"value" => ["{{variable}}", "Much array", "Hey, {{hello_world}}"]}
33
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
33
+      @checker.interpolate_options(@checker.options, @event).should == {
34 34
         "value" => ["hello", "Much array", "Hey, Hello world"]
35 35
       }
36 36
     end
@@ -38,7 +38,7 @@ shared_examples_for LiquidInterpolatable do
38 38
     it "should work recursively" do
39 39
       @checker.options['hash'] = {'recursive' => "{{variable}}"}
40 40
       @checker.options['indifferent_hash'] = ActiveSupport::HashWithIndifferentAccess.new({'recursive' => "{{variable}}"})
41
-      @checker.interpolate_options(@checker.options, @event.payload).should == {
41
+      @checker.interpolate_options(@checker.options, @event).should == {
42 42
           "normal" => "just some normal text",
43 43
           "variable" => "hello",
44 44
           "text" => "Some test with an embedded hello",
@@ -49,8 +49,8 @@ shared_examples_for LiquidInterpolatable do
49 49
     end
50 50
 
51 51
     it "should work for strings" do
52
-      @checker.interpolate_string("{{variable}}", @event.payload).should == "hello"
53
-      @checker.interpolate_string("{{variable}} you", @event.payload).should == "hello you"
52
+      @checker.interpolate_string("{{variable}}", @event).should == "hello"
53
+      @checker.interpolate_string("{{variable}} you", @event).should == "hello you"
54 54
     end
55 55
   end
56 56
 

+ 66 - 0
spec/support/shared_examples/web_request_concern.rb

@@ -0,0 +1,66 @@
1
+require 'spec_helper'
2
+
3
+shared_examples_for WebRequestConcern do
4
+  let(:agent) do
5
+    _agent = described_class.new(:name => "some agent", :options => @valid_options || {})
6
+    _agent.user = users(:jane)
7
+    _agent
8
+  end
9
+
10
+  describe "validations" do
11
+    it "should be valid" do
12
+      agent.should be_valid
13
+    end
14
+
15
+    it "should validate user_agent" do
16
+      agent.options['user_agent'] = nil
17
+      agent.should be_valid
18
+
19
+      agent.options['user_agent'] = ""
20
+      agent.should be_valid
21
+
22
+      agent.options['user_agent'] = "foo"
23
+      agent.should be_valid
24
+
25
+      agent.options['user_agent'] = ["foo"]
26
+      agent.should_not be_valid
27
+
28
+      agent.options['user_agent'] = 1
29
+      agent.should_not be_valid
30
+    end
31
+
32
+    it "should validate headers" do
33
+      agent.options['headers'] = "blah"
34
+      agent.should_not be_valid
35
+
36
+      agent.options['headers'] = ""
37
+      agent.should be_valid
38
+
39
+      agent.options['headers'] = {}
40
+      agent.should be_valid
41
+
42
+      agent.options['headers'] = { 'foo' => 'bar' }
43
+      agent.should be_valid
44
+    end
45
+
46
+    it "should validate basic_auth" do
47
+      agent.options['basic_auth'] = "foo:bar"
48
+      agent.should be_valid
49
+
50
+      agent.options['basic_auth'] = ["foo", "bar"]
51
+      agent.should be_valid
52
+
53
+      agent.options['basic_auth'] = ""
54
+      agent.should be_valid
55
+
56
+      agent.options['basic_auth'] = nil
57
+      agent.should be_valid
58
+
59
+      agent.options['basic_auth'] = "blah"
60
+      agent.should_not be_valid
61
+
62
+      agent.options['basic_auth'] = ["blah"]
63
+      agent.should_not be_valid
64
+    end
65
+  end
66
+end

+ 9 - 0
spec/support/vcr_support.rb

@@ -0,0 +1,9 @@
1
+require 'vcr'
2
+
3
+VCR.configure do |c|
4
+  c.cassette_library_dir = 'spec/cassettes'
5
+  c.allow_http_connections_when_no_cassette = true
6
+  c.hook_into :webmock
7
+  c.default_cassette_options = { record: :new_episodes}
8
+  c.configure_rspec_metadata!
9
+end

default is every 6h · 82948977da - Gogs J1X

default is every 6h

Andrew Cantino 9 年之前
父节点
当前提交
82948977da
共有 2 个文件被更改,包括 4 次插入4 次删除
  1. 2 2
      .env.example
  2. 2 2
      spec/models/scenario_import_spec.rb

+ 2 - 2
.env.example

@@ -151,9 +151,9 @@ ENABLE_SECOND_PRECISION_SCHEDULE=false
151 151
 # at the expense of time accuracy.
152 152
 SCHEDULER_FREQUENCY=0.3
153 153
 
154
-# Specify the frequency with which the scheduler checks for event expiration.
154
+# Specify the frequency with which the scheduler checks for and cleans up expired events.
155 155
 # You can use `m` for minutes, `h` for hours, and `d` for days.
156
-EVENT_EXPIRATION_CHECK=3h
156
+EVENT_EXPIRATION_CHECK=6h
157 157
 
158 158
 # Use Graphviz for generating diagrams instead of using Google Chart
159 159
 # Tools.  Specify a dot(1) command path built with SVG support

+ 2 - 2
spec/models/scenario_import_spec.rb

@@ -348,7 +348,7 @@ describe ScenarioImport do
348 348
             "0" => {
349 349
               "name" => "updated name",
350 350
               "schedule" => "6pm",
351
-              "keep_events_for" => 2.days.to_i,
351
+              "keep_events_for" => 2.days.to_i.to_s,
352 352
               "disabled" => "false",
353 353
               "options" => weather_agent_options.merge("api_key" => "foo").to_json
354 354
             }
@@ -361,7 +361,7 @@ describe ScenarioImport do
361 361
           weather_agent = existing_scenario.agents.find_by(:guid => "a-weather-agent")
362 362
           expect(weather_agent.name).to eq("updated name")
363 363
           expect(weather_agent.schedule).to eq("6pm")
364
-          expect(weather_agent.keep_events_for).to eq(2.days)
364
+          expect(weather_agent.keep_events_for).to eq(2.days.to_i)
365 365
           expect(weather_agent).not_to be_disabled
366 366
           expect(weather_agent.options).to eq(weather_agent_options.merge("api_key" => "foo"))
367 367
         end