async-caldav 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Delete
11
+ module_function
12
+
13
+ def call(path:, storage:, **)
14
+ item = storage.get_item(path.to_s)
15
+
16
+ if item
17
+ storage.delete_item(path.to_s)
18
+ [204, {}, ['']]
19
+ elsif storage.collection_exists?(path.ensure_trailing_slash.to_s)
20
+ storage.delete_collection(path.ensure_trailing_slash.to_s)
21
+ [204, {}, ['']]
22
+ else
23
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+
31
+ test do
32
+ describe "Async::Caldav::Handlers::Delete" do
33
+ def call(**opts)
34
+ Async::Caldav::Handlers::Delete.call(**opts)
35
+ end
36
+
37
+ it "deletes an item and returns 204" do
38
+ s = Async::Caldav::Storage::Mock.new
39
+ s.create_collection('/calendars/admin/cal/', type: :calendar)
40
+ s.put_item('/calendars/admin/cal/event.ics', 'data', 'text/calendar')
41
+ status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/cal/event.ics', storage_class: s), storage: s)
42
+ status.should.equal 204
43
+ s.get_item('/calendars/admin/cal/event.ics').should.be.nil
44
+ end
45
+
46
+ it "deletes a collection and returns 204" do
47
+ s = Async::Caldav::Storage::Mock.new
48
+ s.create_collection('/calendars/admin/cal/', type: :calendar)
49
+ status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/cal/', storage_class: s), storage: s)
50
+ status.should.equal 204
51
+ s.collection_exists?('/calendars/admin/cal/').should.equal false
52
+ end
53
+
54
+ it "returns 404 for non-existent resource" do
55
+ s = Async::Caldav::Storage::Mock.new
56
+ status, = call(path: Protocol::Caldav::Path.new('/calendars/admin/nope/', storage_class: s), storage: s)
57
+ status.should.equal 404
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Get
11
+ module_function
12
+
13
+ def call(path:, storage:, headers: {}, **)
14
+ item = storage.get_item(path.to_s)
15
+ col_path = path.ensure_trailing_slash.to_s
16
+
17
+ if item
18
+ if headers['if-none-match'] == item[:etag]
19
+ [304, { 'etag' => item[:etag], 'cache-control' => 'private, no-cache' }, []]
20
+ else
21
+ [200, { 'content-type' => item[:content_type], 'etag' => item[:etag], 'cache-control' => 'private, no-cache' }, [item[:body]]]
22
+ end
23
+ elsif storage.collection_exists?(col_path)
24
+ items = storage.list_items(col_path)
25
+ body = items.map { |_, data| data[:body] }.join("\n")
26
+ [200, { 'content-type' => 'text/plain' }, [body]]
27
+ elsif path.depth <= 2
28
+ [200, { 'content-type' => 'text/html' }, ['<html><body>CalDAV</body></html>']]
29
+ else
30
+ [404, { 'content-type' => 'text/plain' }, ['Not Found']]
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+
38
+ test do
39
+ describe "Async::Caldav::Handlers::Get" do
40
+ def call(**opts)
41
+ Async::Caldav::Handlers::Get.call(**opts)
42
+ end
43
+
44
+ def path(p, s)
45
+ Protocol::Caldav::Path.new(p, storage_class: s)
46
+ end
47
+
48
+ it "returns item body with 200" do
49
+ s = Async::Caldav::Storage::Mock.new
50
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
51
+ status, headers, body = call(path: path('/cal/ev.ics', s), storage: s)
52
+ status.should.equal 200
53
+ headers['content-type'].should.equal 'text/calendar'
54
+ body[0].should.equal 'BEGIN:VCALENDAR'
55
+ end
56
+
57
+ it "returns 304 on If-None-Match hit" do
58
+ s = Async::Caldav::Storage::Mock.new
59
+ item, = s.put_item('/cal/ev.ics', 'data', 'text/calendar')
60
+ status, = call(path: path('/cal/ev.ics', s), storage: s, headers: { 'if-none-match' => item[:etag] })
61
+ status.should.equal 304
62
+ end
63
+
64
+ it "returns collection contents" do
65
+ s = Async::Caldav::Storage::Mock.new
66
+ s.create_collection('/cal/')
67
+ s.put_item('/cal/a.ics', 'A', 'text/calendar')
68
+ s.put_item('/cal/b.ics', 'B', 'text/calendar')
69
+ status, _, body = call(path: path('/cal/', s), storage: s)
70
+ status.should.equal 200
71
+ body[0].should.include 'A'
72
+ body[0].should.include 'B'
73
+ end
74
+
75
+ it "returns 404 for deep non-existent path" do
76
+ s = Async::Caldav::Storage::Mock.new
77
+ status, = call(path: path('/calendars/admin/nope/item.ics', s), storage: s)
78
+ status.should.equal 404
79
+ end
80
+
81
+ it "returns 200 for shallow path" do
82
+ s = Async::Caldav::Storage::Mock.new
83
+ status, = call(path: path('/calendars/admin/', s), storage: s)
84
+ status.should.equal 200
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Head
11
+ module_function
12
+
13
+ def call(**opts)
14
+ status, headers, _body = Get.call(**opts)
15
+ [status, headers, []]
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+
22
+ test do
23
+ describe "Async::Caldav::Handlers::Head" do
24
+ it "returns same status and headers as GET but empty body" do
25
+ s = Async::Caldav::Storage::Mock.new
26
+ s.put_item('/cal/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
27
+ status, headers, body = Async::Caldav::Handlers::Head.call(
28
+ path: Protocol::Caldav::Path.new('/cal/ev.ics', storage_class: s),
29
+ storage: s
30
+ )
31
+ status.should.equal 200
32
+ headers['content-type'].should.equal 'text/calendar'
33
+ body.should.equal []
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Mkcol
11
+ module_function
12
+
13
+ # method: 'MKCALENDAR' or 'MKCOL'
14
+ def call(path:, body:, storage:, method: 'MKCOL', resource_type: nil, **)
15
+ col_path = path.ensure_trailing_slash
16
+
17
+ if storage.collection_exists?(col_path.to_s)
18
+ return [405, { 'content-type' => 'text/plain' }, ['Collection already exists']]
19
+ end
20
+
21
+ unless col_path.parent_exists?
22
+ return [409, { 'content-type' => 'text/plain' }, ['Parent collection does not exist']]
23
+ end
24
+
25
+ displayname = Protocol::Caldav::Xml.extract_value(body, 'displayname')
26
+ description = Protocol::Caldav::Xml.extract_value(body, 'calendar-description')
27
+ color = Protocol::Caldav::Xml.extract_value(body, 'calendar-color')
28
+
29
+ type = if method == 'MKCALENDAR'
30
+ :calendar
31
+ elsif body && body.include?('addressbook')
32
+ :addressbook
33
+ else
34
+ resource_type || :collection
35
+ end
36
+
37
+ storage.create_collection(col_path.to_s, type: type, displayname: displayname, description: description, color: color)
38
+ [201, {}, ['']]
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ test do
46
+ describe "Async::Caldav::Handlers::Mkcol" do
47
+ def call(**opts)
48
+ Async::Caldav::Handlers::Mkcol.call(**opts)
49
+ end
50
+
51
+ def path(p, s)
52
+ Protocol::Caldav::Path.new(p, storage_class: s)
53
+ end
54
+
55
+ it "creates a calendar and returns 201" do
56
+ s = Async::Caldav::Storage::Mock.new
57
+ s.create_collection('/calendars/admin/')
58
+ status, = call(
59
+ path: path('/calendars/admin/work', s), storage: s,
60
+ body: '<d:displayname>Work</d:displayname>', method: 'MKCALENDAR'
61
+ )
62
+ status.should.equal 201
63
+ col = s.get_collection('/calendars/admin/work/')
64
+ col[:type].should.equal :calendar
65
+ col[:displayname].should.equal 'Work'
66
+ end
67
+
68
+ it "creates an addressbook via MKCOL" do
69
+ s = Async::Caldav::Storage::Mock.new
70
+ s.create_collection('/addressbooks/admin/')
71
+ status, = call(
72
+ path: path('/addressbooks/admin/contacts', s), storage: s,
73
+ body: '<resourcetype><addressbook/></resourcetype>', method: 'MKCOL'
74
+ )
75
+ status.should.equal 201
76
+ s.get_collection('/addressbooks/admin/contacts/')[:type].should.equal :addressbook
77
+ end
78
+
79
+ it "returns 405 if collection exists" do
80
+ s = Async::Caldav::Storage::Mock.new
81
+ s.create_collection('/cal/')
82
+ status, = call(path: path('/cal/', s), storage: s, body: '', method: 'MKCALENDAR')
83
+ status.should.equal 405
84
+ end
85
+
86
+ it "returns 409 if parent does not exist" do
87
+ s = Async::Caldav::Storage::Mock.new
88
+ status, = call(
89
+ path: path('/calendars/admin/deep/nested/cal', s), storage: s,
90
+ body: '', method: 'MKCALENDAR'
91
+ )
92
+ status.should.equal 409
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+ require 'uri'
7
+
8
+ module Async
9
+ module Caldav
10
+ module Handlers
11
+ module Move
12
+ module_function
13
+
14
+ def call(path:, storage:, headers: {}, **)
15
+ destination = headers['destination']
16
+ return [400, { 'content-type' => 'text/plain' }, ['Missing Destination header']] unless destination
17
+
18
+ to_path = URI.parse(destination).path
19
+ overwrite = headers['overwrite'] != 'F'
20
+
21
+ source = storage.get_item(path.to_s)
22
+ return [404, { 'content-type' => 'text/plain' }, ['Not Found']] unless source
23
+
24
+ existing = storage.get_item(to_path)
25
+
26
+ if existing && !overwrite
27
+ return [412, { 'content-type' => 'text/plain' }, ['Precondition Failed']]
28
+ end
29
+
30
+ # UID conflict check when destination doesn't already exist
31
+ if !existing
32
+ uid = extract_uid(source[:body])
33
+ if uid
34
+ dest_col = Protocol::Caldav::Path.new(to_path).parent.to_s
35
+ items = storage.list_items(dest_col)
36
+ items.each do |item_path, item_data|
37
+ next if item_path == path.to_s
38
+ if extract_uid(item_data[:body]) == uid
39
+ return [409, { 'content-type' => 'text/plain' }, ['UID conflict']]
40
+ end
41
+ end
42
+ end
43
+ end
44
+
45
+ storage.move_item(path.to_s, to_path)
46
+
47
+ if existing
48
+ [204, {}, ['']]
49
+ else
50
+ [201, {}, ['']]
51
+ end
52
+ end
53
+
54
+ def extract_uid(body)
55
+ return nil unless body
56
+ match = body.match(/^UID:(.+)/i)
57
+ match ? match[1].strip : nil
58
+ end
59
+
60
+ private_class_method :extract_uid
61
+ end
62
+ end
63
+ end
64
+ end
65
+
66
+ test do
67
+ describe "Async::Caldav::Handlers::Move" do
68
+ def call(**opts)
69
+ Async::Caldav::Handlers::Move.call(**opts)
70
+ end
71
+
72
+ def path(p, s)
73
+ Protocol::Caldav::Path.new(p, storage_class: s)
74
+ end
75
+
76
+ it "moves an item and returns 201" do
77
+ s = Async::Caldav::Storage::Mock.new
78
+ s.create_collection('/cal/')
79
+ s.put_item('/cal/a.ics', 'BEGIN:VCALENDAR', 'text/calendar')
80
+ status, = call(
81
+ path: path('/cal/a.ics', s), storage: s,
82
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
83
+ )
84
+ status.should.equal 201
85
+ s.get_item('/cal/a.ics').should.be.nil
86
+ s.get_item('/cal/b.ics').should.not.be.nil
87
+ end
88
+
89
+ it "overwrites and returns 204" do
90
+ s = Async::Caldav::Storage::Mock.new
91
+ s.put_item('/cal/a.ics', 'A', 'text/calendar')
92
+ s.put_item('/cal/b.ics', 'B', 'text/calendar')
93
+ status, = call(
94
+ path: path('/cal/a.ics', s), storage: s,
95
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
96
+ )
97
+ status.should.equal 204
98
+ end
99
+
100
+ it "returns 400 without Destination header" do
101
+ s = Async::Caldav::Storage::Mock.new
102
+ status, = call(path: path('/cal/a.ics', s), storage: s, headers: {})
103
+ status.should.equal 400
104
+ end
105
+
106
+ it "returns 404 when source missing" do
107
+ s = Async::Caldav::Storage::Mock.new
108
+ status, = call(
109
+ path: path('/cal/nope.ics', s), storage: s,
110
+ headers: { 'destination' => 'http://localhost/cal/b.ics' }
111
+ )
112
+ status.should.equal 404
113
+ end
114
+
115
+ it "returns 412 when Overwrite=F and destination exists" do
116
+ s = Async::Caldav::Storage::Mock.new
117
+ s.put_item('/cal/a.ics', 'A', 'text/calendar')
118
+ s.put_item('/cal/b.ics', 'B', 'text/calendar')
119
+ status, = call(
120
+ path: path('/cal/a.ics', s), storage: s,
121
+ headers: { 'destination' => 'http://localhost/cal/b.ics', 'overwrite' => 'F' }
122
+ )
123
+ status.should.equal 412
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Options
11
+ module_function
12
+
13
+ def call(path:, storage:, **)
14
+ [200, Protocol::Caldav::Constants::DAV_HEADERS.merge('content-length' => '0'), []]
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+
21
+ test do
22
+ describe "Async::Caldav::Handlers::Options" do
23
+ it "returns 200 with DAV headers" do
24
+ status, headers, = Async::Caldav::Handlers::Options.call(
25
+ path: Protocol::Caldav::Path.new("/calendars/admin/cal/"),
26
+ storage: Async::Caldav::Storage::Mock.new
27
+ )
28
+ status.should.equal 200
29
+ headers['dav'].should.include 'calendar-access'
30
+ headers['allow'].should.include 'PROPFIND'
31
+ headers['content-length'].should.equal '0'
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,201 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "scampi"
5
+ require "async/caldav"
6
+
7
+ module Async
8
+ module Caldav
9
+ module Handlers
10
+ module Propfind
11
+ module_function
12
+
13
+ def call(path:, storage:, user:, headers: {}, body: nil, **)
14
+ depth = headers['depth'] || '1'
15
+ propname = body&.include?('propname')
16
+
17
+ col_path = path.ensure_trailing_slash
18
+ collection = storage.get_collection(col_path.to_s)
19
+ item_data = storage.get_item(path.to_s)
20
+
21
+ # Non-existent deep path
22
+ if !collection && !item_data && path.depth > 2
23
+ return [404, { 'content-type' => 'text/plain' }, ['Not Found']]
24
+ end
25
+
26
+ responses = []
27
+
28
+ if collection
29
+ col = Protocol::Caldav::Collection.new(
30
+ path: col_path,
31
+ type: collection[:type],
32
+ displayname: collection[:displayname],
33
+ description: collection[:description],
34
+ color: collection[:color],
35
+ props: collection[:props]
36
+ )
37
+ responses << (propname ? col.to_propname_xml : col.to_propfind_xml)
38
+
39
+ if depth != '0'
40
+ # Child collections
41
+ storage.list_collections(col_path.to_s).each do |child_path, child_data|
42
+ child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
43
+ child_col = Protocol::Caldav::Collection.new(
44
+ path: child_p,
45
+ type: child_data[:type],
46
+ displayname: child_data[:displayname],
47
+ description: child_data[:description],
48
+ color: child_data[:color],
49
+ props: child_data[:props]
50
+ )
51
+ responses << (propname ? child_col.to_propname_xml : child_col.to_propfind_xml)
52
+ end
53
+
54
+ # Child items
55
+ storage.list_items(col_path.to_s).each do |item_path, data|
56
+ item_p = Protocol::Caldav::Path.new(item_path, storage_class: storage)
57
+ item = Protocol::Caldav::Item.new(
58
+ path: item_p,
59
+ body: data[:body],
60
+ content_type: data[:content_type],
61
+ etag: data[:etag]
62
+ )
63
+ responses << (propname ? item.to_propname_xml : item.to_propfind_xml)
64
+ end
65
+ end
66
+ elsif item_data
67
+ item = Protocol::Caldav::Item.new(
68
+ path: path,
69
+ body: item_data[:body],
70
+ content_type: item_data[:content_type],
71
+ etag: item_data[:etag]
72
+ )
73
+ responses << (propname ? item.to_propname_xml : item.to_propfind_xml)
74
+ else
75
+ # Shallow path with no collection — return basic discovery info
76
+ responses << build_discovery_xml(path, user)
77
+
78
+ # Still list child collections/items for depth=1
79
+ if depth != '0'
80
+ storage.list_collections(col_path.to_s).each do |child_path, child_data|
81
+ child_p = Protocol::Caldav::Path.new(child_path, storage_class: storage)
82
+ child_col = Protocol::Caldav::Collection.new(
83
+ path: child_p,
84
+ type: child_data[:type],
85
+ displayname: child_data[:displayname],
86
+ description: child_data[:description],
87
+ color: child_data[:color],
88
+ props: child_data[:props]
89
+ )
90
+ responses << (propname ? child_col.to_propname_xml : child_col.to_propfind_xml)
91
+ end
92
+ end
93
+ end
94
+
95
+ xml = Protocol::Caldav::Multistatus.new(responses).to_xml
96
+ [207, Protocol::Caldav::Constants::DAV_HEADERS, [xml]]
97
+ end
98
+
99
+ def build_discovery_xml(path, user)
100
+ <<~XML
101
+ <d:response>
102
+ <d:href>#{Protocol::Caldav::Xml.escape(path.to_s)}</d:href>
103
+ <d:propstat>
104
+ <d:prop>
105
+ <d:resourcetype><d:collection/></d:resourcetype>
106
+ <d:current-user-principal><d:href>/#{user}/</d:href></d:current-user-principal>
107
+ <c:calendar-home-set><d:href>/calendars/#{user}/</d:href></c:calendar-home-set>
108
+ <cr:addressbook-home-set><d:href>/addressbooks/#{user}/</d:href></cr:addressbook-home-set>
109
+ </d:prop>
110
+ <d:status>HTTP/1.1 200 OK</d:status>
111
+ </d:propstat>
112
+ </d:response>
113
+ XML
114
+ end
115
+
116
+ private_class_method :build_discovery_xml
117
+ end
118
+ end
119
+ end
120
+ end
121
+
122
+ test do
123
+ def normalize(xml)
124
+ xml.gsub(/>\s+</, '><').strip
125
+ end
126
+
127
+ describe "Async::Caldav::Handlers::Propfind" do
128
+ def call(**opts)
129
+ Async::Caldav::Handlers::Propfind.call(**opts)
130
+ end
131
+
132
+ def path(p, s)
133
+ Protocol::Caldav::Path.new(p, storage_class: s)
134
+ end
135
+
136
+ it "returns 207 with collection properties" do
137
+ s = Async::Caldav::Storage::Mock.new
138
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
139
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '0' })
140
+ status.should.equal 207
141
+ body[0].should.include 'Work'
142
+ body[0].should.include 'calendar'
143
+ end
144
+
145
+ it "depth=1 includes child items" do
146
+ s = Async::Caldav::Storage::Mock.new
147
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
148
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
149
+ _, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin', headers: { 'depth' => '1' })
150
+ body[0].should.include 'ev.ics'
151
+ end
152
+
153
+ it "returns 404 for deep non-existent path" do
154
+ s = Async::Caldav::Storage::Mock.new
155
+ status, = call(path: path('/calendars/admin/nope/deep/', s), storage: s, user: 'admin')
156
+ status.should.equal 404
157
+ end
158
+
159
+ it "returns discovery info for shallow path" do
160
+ s = Async::Caldav::Storage::Mock.new
161
+ status, _, body = call(path: path('/', s), storage: s, user: 'admin')
162
+ status.should.equal 207
163
+ body[0].should.include 'current-user-principal'
164
+ body[0].should.include '/admin/'
165
+ body[0].should.include 'calendar-home-set'
166
+ end
167
+
168
+ it "propname request returns property names without values" do
169
+ s = Async::Caldav::Storage::Mock.new
170
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
171
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
172
+ propname_body = '<d:propfind xmlns:d="DAV:"><d:propname/></d:propfind>'
173
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
174
+ headers: { 'depth' => '1' }, body: propname_body)
175
+ status.should.equal 207
176
+ body[0].should.include '<d:resourcetype/>'
177
+ body[0].should.include '<d:getetag/>'
178
+ end
179
+
180
+ it "allprop request returns all properties with values" do
181
+ s = Async::Caldav::Storage::Mock.new
182
+ s.create_collection('/calendars/admin/work/', type: :calendar, displayname: 'Work')
183
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
184
+ allprop_body = '<d:propfind xmlns:d="DAV:"><d:allprop/></d:propfind>'
185
+ status, _, body = call(path: path('/calendars/admin/work/', s), storage: s, user: 'admin',
186
+ headers: { 'depth' => '1' }, body: allprop_body)
187
+ status.should.equal 207
188
+ body[0].should.include 'displayname'
189
+ body[0].should.include 'Work'
190
+ body[0].should.include 'getetag'
191
+ end
192
+
193
+ it "returns item propfind for a single item" do
194
+ s = Async::Caldav::Storage::Mock.new
195
+ s.put_item('/calendars/admin/work/ev.ics', 'BEGIN:VCALENDAR', 'text/calendar')
196
+ status, _, body = call(path: path('/calendars/admin/work/ev.ics', s), storage: s, user: 'admin')
197
+ status.should.equal 207
198
+ body[0].should.include 'getetag'
199
+ end
200
+ end
201
+ end